Skip to main content

iroh_netbench/
responder.rs

1//! Responder-side benchmark protocol handler.
2
3use std::{
4    collections::HashSet,
5    sync::{
6        Arc,
7        atomic::{AtomicBool, AtomicU64, Ordering},
8    },
9    time::Duration,
10};
11
12use tokio::{
13    sync::Semaphore,
14    task::{JoinHandle, JoinSet},
15    time::{Instant, MissedTickBehavior},
16};
17
18use crate::{
19    Error, NetBenchFlow, NetBenchReceiveStream, NetBenchSendStream, NetBenchSession,
20    PROTOCOL_VERSION, Result,
21    config::{LOADED_LATENCY_INTERVAL, MAX_PROBE_RATE_PER_SECOND, THROUGHPUT_SAMPLE_INTERVAL},
22    wire::{
23        Capabilities, ControlMessage, ErrorCode, PROBE_MAGIC, Probe, ProbeKind, ServerLimits,
24        read_control, write_control,
25    },
26};
27
28const DEFAULT_MAX_CHUNK_SIZE: u32 = 1024 * 1024;
29const DOWNLOAD_STREAM_MAGIC: u8 = 0x44;
30const UPLOAD_STREAM_MAGIC: u8 = 0x55;
31const THROUGHPUT_SETUP_TIMEOUT: Duration = Duration::from_secs(5);
32const THROUGHPUT_CLEANUP_TIMEOUT: Duration = Duration::from_secs(5);
33type SendStream = Box<dyn NetBenchSendStream>;
34type RecvStream = Box<dyn NetBenchReceiveStream>;
35
36struct ProbeEchoTask {
37    test_id: u64,
38    task: Option<JoinHandle<Result<()>>>,
39}
40
41impl ProbeEchoTask {
42    fn spawn(
43        session: Arc<dyn NetBenchSession>,
44        test_id: u64,
45        lifetime: Duration,
46        max_requests: u64,
47    ) -> Self {
48        Self {
49            test_id,
50            task: Some(tokio::spawn(echo_datagrams(
51                session,
52                test_id,
53                lifetime,
54                max_requests,
55            ))),
56        }
57    }
58
59    async fn stop(mut self) -> Result<()> {
60        let Some(task) = self.task.take() else {
61            return Ok(());
62        };
63        if !task.is_finished() {
64            task.abort();
65        }
66        match task.await {
67            Ok(result) => result,
68            Err(error) if error.is_cancelled() => Ok(()),
69            Err(error) => Err(Error::Protocol(format!(
70                "probe echo task ended unexpectedly: {error}"
71            ))),
72        }
73    }
74}
75
76impl Drop for ProbeEchoTask {
77    fn drop(&mut self) {
78        if let Some(task) = &self.task {
79            task.abort();
80        }
81    }
82}
83
84#[derive(Clone)]
85struct Connection(Arc<dyn NetBenchSession>);
86
87impl Connection {
88    fn max_datagram_size(&self) -> Option<usize> {
89        self.0.max_datagram_size()
90    }
91
92    async fn open_uni(&self) -> Result<SendStream> {
93        Ok(self.0.open_bi().await?.into_split().0)
94    }
95
96    async fn accept_uni(&self) -> Result<RecvStream> {
97        Ok(self.0.accept_bi().await?.into_split().1)
98    }
99}
100
101/// Receiver-side policy for bandwidth-saturating upload and download measurements.
102///
103/// This policy does not affect connectivity checks or low-bandwidth latency and loss probes.
104#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
105pub enum ThroughputPolicy {
106    /// Accept throughput measurements, subject to the configured server limits.
107    #[default]
108    Allow,
109    /// Reject throughput measurements while continuing to serve low-bandwidth probes.
110    Deny,
111}
112
113/// Server-side benchmark protocol configuration.
114#[derive(Clone)]
115pub struct NetBenchResponder {
116    concurrent_tests: usize,
117    test_duration: Duration,
118    parallel_streams: u16,
119    chunk_size: u32,
120    throughput_allowed: Arc<AtomicBool>,
121    permits: Arc<Semaphore>,
122}
123
124impl std::fmt::Debug for NetBenchResponder {
125    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126        formatter
127            .debug_struct("NetBenchResponder")
128            .field("max_concurrent_tests", &self.concurrent_tests)
129            .field("max_test_duration", &self.test_duration)
130            .field("max_parallel_streams", &self.parallel_streams)
131            .field("throughput_policy", &self.throughput_policy())
132            .field("max_chunk_size", &self.chunk_size)
133            .finish_non_exhaustive()
134    }
135}
136
137impl NetBenchResponder {
138    /// Starts configuring a protocol handler.
139    #[must_use]
140    pub fn builder() -> NetBenchResponderBuilder {
141        NetBenchResponderBuilder::default()
142    }
143
144    /// Configured simultaneous-test limit.
145    #[must_use]
146    pub const fn max_concurrent_tests(&self) -> usize {
147        self.concurrent_tests
148    }
149
150    /// Configured per-phase duration limit.
151    #[must_use]
152    pub const fn max_test_duration(&self) -> Duration {
153        self.test_duration
154    }
155
156    /// Configured parallel-stream limit.
157    #[must_use]
158    pub const fn max_parallel_streams(&self) -> u16 {
159        self.parallel_streams
160    }
161
162    /// Current receiver-side policy for upload and download throughput measurements.
163    #[must_use]
164    pub fn throughput_policy(&self) -> ThroughputPolicy {
165        if self.throughput_allowed.load(Ordering::Relaxed) {
166            ThroughputPolicy::Allow
167        } else {
168            ThroughputPolicy::Deny
169        }
170    }
171
172    /// Changes the receiver-side throughput policy for future measurement phases.
173    ///
174    /// Existing low-bandwidth latency and loss probes remain available under either policy.
175    pub fn set_throughput_policy(&self, policy: ThroughputPolicy) {
176        self.throughput_allowed
177            .store(policy == ThroughputPolicy::Allow, Ordering::Relaxed);
178    }
179
180    fn limits(&self) -> ServerLimits {
181        ServerLimits {
182            test_duration_ms: duration_ms(self.test_duration),
183            // Zero is the protocol-v1 signal that no throughput streams are accepted.
184            parallel_streams: if self.throughput_policy() == ThroughputPolicy::Allow {
185                self.parallel_streams
186            } else {
187                0
188            },
189            chunk_size: self.chunk_size,
190        }
191    }
192
193    fn validate_phase(&self, duration_ms: u32) -> Result<Duration> {
194        let requested = Duration::from_millis(u64::from(duration_ms));
195        if requested.is_zero() || requested > self.test_duration {
196            return Err(Error::DurationLimitExceeded {
197                requested,
198                maximum: self.test_duration,
199            });
200        }
201        Ok(requested)
202    }
203
204    fn validate_throughput(
205        &self,
206        duration_ms: u32,
207        streams: u16,
208        chunk_size: u32,
209    ) -> Result<Duration> {
210        if self.throughput_policy() == ThroughputPolicy::Deny || self.parallel_streams == 0 {
211            return Err(Error::ThroughputDeniedByPeer);
212        }
213        let duration = self.validate_phase(duration_ms)?;
214        if streams == 0 || streams > self.parallel_streams {
215            return Err(Error::Protocol(format!(
216                "stream count {streams} is outside 1..={}",
217                self.parallel_streams
218            )));
219        }
220        if chunk_size == 0 || chunk_size > self.chunk_size {
221            return Err(Error::Protocol(format!(
222                "chunk size {chunk_size} is outside 1..={}",
223                self.chunk_size
224            )));
225        }
226        Ok(duration)
227    }
228
229    #[allow(
230        clippy::too_many_lines,
231        reason = "linear command dispatch is easier to audit"
232    )]
233    /// Serves one host-admitted benchmark flow.
234    ///
235    /// # Errors
236    ///
237    /// Returns a flow-local negotiation, policy, measurement, timeout, or transport error.
238    pub async fn serve(&self, flow: NetBenchFlow) -> Result<()> {
239        let (session, mut control_send, mut control_recv) = flow.into_parts();
240        let connection = Connection(Arc::clone(&session));
241        let permit = Arc::clone(&self.permits).try_acquire_owned();
242
243        let Ok(_permit) = permit else {
244            write_control(
245                &mut control_send,
246                &ControlMessage::Error {
247                    code: ErrorCode::Busy,
248                    message: "server concurrency limit reached".to_owned(),
249                },
250            )
251            .await?;
252            control_send.finish().map_err(Error::network)?;
253            return Ok(());
254        };
255
256        let hello = read_control(&mut control_recv).await?;
257        let ControlMessage::ClientHello {
258            protocol_versions,
259            capabilities: _,
260        } = hello
261        else {
262            return Err(Error::Protocol(
263                "first control message must be ClientHello".to_owned(),
264            ));
265        };
266        if !protocol_versions.contains(&PROTOCOL_VERSION) {
267            write_control(
268                &mut control_send,
269                &ControlMessage::Error {
270                    code: ErrorCode::UnsupportedVersion,
271                    message: "no mutually supported protocol version".to_owned(),
272                },
273            )
274            .await?;
275            control_send.finish().map_err(Error::network)?;
276            return Ok(());
277        }
278
279        write_control(
280            &mut control_send,
281            &ControlMessage::ServerHello {
282                selected_version: PROTOCOL_VERSION,
283                limits: self.limits(),
284                capabilities: Capabilities {
285                    datagram_probes: connection.max_datagram_size().is_some(),
286                    loaded_latency: true,
287                    path_stats: true,
288                },
289            },
290        )
291        .await?;
292
293        let mut active_probe = None::<ProbeEchoTask>;
294
295        loop {
296            let message = read_control(&mut control_recv).await?;
297
298            let result = match message {
299                ControlMessage::StartLatency {
300                    test_id,
301                    duration_ms,
302                    interval_ms,
303                } => self
304                    .validate_phase(duration_ms)
305                    .and_then(|_| latency_probe_budget(duration_ms, interval_ms))
306                    .and_then(|max_requests| {
307                        start_probe_echo(
308                            &mut active_probe,
309                            Arc::clone(&session),
310                            test_id,
311                            self.test_duration,
312                            max_requests,
313                        )
314                    }),
315                ControlMessage::StartLoss {
316                    test_id,
317                    duration_ms,
318                    rate_per_second,
319                    timeout_ms: _,
320                } => self
321                    .validate_phase(duration_ms)
322                    .and_then(|_| loss_probe_budget(duration_ms, rate_per_second))
323                    .and_then(|max_requests| {
324                        start_probe_echo(
325                            &mut active_probe,
326                            Arc::clone(&session),
327                            test_id,
328                            self.test_duration,
329                            max_requests,
330                        )
331                    }),
332                ControlMessage::StopTest { test_id } => match active_probe.take() {
333                    None => Err(Error::Protocol(format!(
334                        "received StopTest for inactive test {test_id}"
335                    ))),
336                    Some(probe) if probe.test_id != test_id => {
337                        let active_test_id = probe.test_id;
338                        active_probe = Some(probe);
339                        Err(Error::Protocol(format!(
340                            "received StopTest for test {test_id}, active test is {active_test_id}"
341                        )))
342                    }
343                    Some(probe) => probe.stop().await,
344                },
345                ControlMessage::FlowFinished => {
346                    if let Some(probe) = &active_probe {
347                        Err(Error::Protocol(format!(
348                            "flow finished while probe test {} is still active",
349                            probe.test_id
350                        )))
351                    } else {
352                        write_control(&mut control_send, &ControlMessage::FlowFinishedAck).await?;
353                        break;
354                    }
355                }
356                ControlMessage::StartDownload {
357                    test_id,
358                    duration_ms,
359                    streams,
360                    chunk_size,
361                } => {
362                    if let Some(probe) = &active_probe {
363                        let error = Error::Protocol(format!(
364                            "cannot start download test {test_id} while probe test {} is active",
365                            probe.test_id
366                        ));
367                        send_error_best_effort(
368                            &mut control_send,
369                            ErrorCode::InvalidRequest,
370                            error.to_string(),
371                        )
372                        .await;
373                        return Err(error);
374                    }
375                    let duration = match self.validate_throughput(duration_ms, streams, chunk_size)
376                    {
377                        Ok(duration) => duration,
378                        Err(error) => {
379                            send_error_best_effort(
380                                &mut control_send,
381                                ErrorCode::InvalidRequest,
382                                error.to_string(),
383                            )
384                            .await;
385                            return Err(error);
386                        }
387                    };
388                    let phase_timeout = duration
389                        .saturating_add(THROUGHPUT_SETUP_TIMEOUT)
390                        .saturating_add(THROUGHPUT_CLEANUP_TIMEOUT);
391                    tokio::time::timeout(phase_timeout, async {
392                        let download_streams =
393                            open_download_streams(connection.clone(), streams).await?;
394                        write_control(&mut control_send, &ControlMessage::TestReady { test_id })
395                            .await?;
396                        expect_test_ready(&mut control_recv, test_id).await?;
397                        let probe = ProbeEchoTask::spawn(
398                            Arc::clone(&session),
399                            test_id,
400                            duration.saturating_add(THROUGHPUT_CLEANUP_TIMEOUT),
401                            loaded_probe_budget(duration),
402                        );
403                        let download = send_download(
404                            download_streams,
405                            duration,
406                            usize::try_from(chunk_size).map_err(Error::network)?,
407                        )
408                        .await;
409                        let probe_result = probe.stop().await;
410                        let (bytes, elapsed) = download?;
411                        probe_result?;
412                        tracing::debug!(
413                            test_id,
414                            direction = "download",
415                            received_bytes = bytes,
416                            measurement_duration = ?elapsed,
417                            "throughput data tasks completed; queueing completion on the prioritized control stream"
418                        );
419                        write_control(
420                            &mut control_send,
421                            &ControlMessage::TestFinished {
422                                test_id,
423                                received_bytes: bytes,
424                                duration_ns: duration_ns(elapsed),
425                            },
426                        )
427                        .await
428                    })
429                    .await
430                    .map_err(|_| Error::Timeout {
431                        stage: "download phase",
432                    })?
433                }
434                ControlMessage::StartUpload {
435                    test_id,
436                    duration_ms,
437                    streams,
438                    chunk_size,
439                } => {
440                    if let Some(probe) = &active_probe {
441                        let error = Error::Protocol(format!(
442                            "cannot start upload test {test_id} while probe test {} is active",
443                            probe.test_id
444                        ));
445                        send_error_best_effort(
446                            &mut control_send,
447                            ErrorCode::InvalidRequest,
448                            error.to_string(),
449                        )
450                        .await;
451                        return Err(error);
452                    }
453                    let duration = match self.validate_throughput(duration_ms, streams, chunk_size)
454                    {
455                        Ok(duration) => duration,
456                        Err(error) => {
457                            send_error_best_effort(
458                                &mut control_send,
459                                ErrorCode::InvalidRequest,
460                                error.to_string(),
461                            )
462                            .await;
463                            return Err(error);
464                        }
465                    };
466                    let phase_timeout = duration
467                        .saturating_add(THROUGHPUT_SETUP_TIMEOUT)
468                        .saturating_add(THROUGHPUT_CLEANUP_TIMEOUT);
469                    tokio::time::timeout(phase_timeout, async {
470                        let upload_streams =
471                            accept_upload_streams(connection.clone(), streams).await?;
472                        write_control(&mut control_send, &ControlMessage::TestReady { test_id })
473                            .await?;
474                        let probe = ProbeEchoTask::spawn(
475                            Arc::clone(&session),
476                            test_id,
477                            duration.saturating_add(THROUGHPUT_CLEANUP_TIMEOUT),
478                            loaded_probe_budget(duration),
479                        );
480                        let upload =
481                            receive_upload(upload_streams, &mut control_send, test_id, duration)
482                                .await;
483                        let probe_result = probe.stop().await;
484                        let (bytes, elapsed) = upload?;
485                        probe_result?;
486                        tracing::debug!(
487                            test_id,
488                            direction = "upload",
489                            received_bytes = bytes,
490                            measurement_duration = ?elapsed,
491                            "throughput data tasks completed; queueing completion on the prioritized control stream"
492                        );
493                        write_control(
494                            &mut control_send,
495                            &ControlMessage::TestFinished {
496                                test_id,
497                                received_bytes: bytes,
498                                duration_ns: duration_ns(elapsed),
499                            },
500                        )
501                        .await
502                    })
503                    .await
504                    .map_err(|_| Error::Timeout {
505                        stage: "upload phase",
506                    })?
507                }
508                ControlMessage::ClientHello { .. }
509                | ControlMessage::ServerHello { .. }
510                | ControlMessage::TestReady { .. }
511                | ControlMessage::ThroughputProgress { .. }
512                | ControlMessage::TestFinished { .. }
513                | ControlMessage::FlowFinishedAck
514                | ControlMessage::Error { .. } => Err(Error::Protocol(
515                    "message is invalid in server command state".to_owned(),
516                )),
517            };
518
519            if let Err(error) = result {
520                send_error_best_effort(
521                    &mut control_send,
522                    ErrorCode::InvalidRequest,
523                    error.to_string(),
524                )
525                .await;
526                return Err(error);
527            }
528        }
529
530        Ok(())
531    }
532}
533
534impl Default for NetBenchResponder {
535    fn default() -> Self {
536        Self::builder().build()
537    }
538}
539
540/// Builder for [`NetBenchResponder`].
541#[derive(Clone)]
542pub struct NetBenchResponderBuilder {
543    concurrent_tests: usize,
544    test_duration: Duration,
545    parallel_streams: u16,
546    chunk_size: u32,
547    throughput_policy: ThroughputPolicy,
548}
549
550impl std::fmt::Debug for NetBenchResponderBuilder {
551    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
552        formatter
553            .debug_struct("NetBenchResponderBuilder")
554            .field("max_concurrent_tests", &self.concurrent_tests)
555            .field("max_test_duration", &self.test_duration)
556            .field("max_parallel_streams", &self.parallel_streams)
557            .field("throughput_policy", &self.throughput_policy)
558            .field("max_chunk_size", &self.chunk_size)
559            .finish()
560    }
561}
562
563impl NetBenchResponderBuilder {
564    /// Sets the number of simultaneous benchmark flows.
565    #[must_use]
566    pub const fn max_concurrent_tests(mut self, value: usize) -> Self {
567        self.concurrent_tests = value;
568        self
569    }
570
571    /// Sets the maximum accepted duration for an individual phase.
572    #[must_use]
573    pub const fn max_test_duration(mut self, value: Duration) -> Self {
574        self.test_duration = value;
575        self
576    }
577
578    /// Sets the maximum stream count per throughput direction.
579    #[must_use]
580    pub const fn max_parallel_streams(mut self, value: u16) -> Self {
581        self.parallel_streams = value;
582        self
583    }
584
585    /// Sets whether peers may run bandwidth-saturating upload and download measurements.
586    #[must_use]
587    pub const fn throughput_policy(mut self, value: ThroughputPolicy) -> Self {
588        self.throughput_policy = value;
589        self
590    }
591
592    /// Builds the server policy.
593    #[must_use]
594    pub fn build(self) -> NetBenchResponder {
595        NetBenchResponder {
596            concurrent_tests: self.concurrent_tests,
597            test_duration: self.test_duration,
598            parallel_streams: self.parallel_streams,
599            chunk_size: self.chunk_size,
600            throughput_allowed: Arc::new(AtomicBool::new(matches!(
601                self.throughput_policy,
602                ThroughputPolicy::Allow
603            ))),
604            permits: Arc::new(Semaphore::new(self.concurrent_tests)),
605        }
606    }
607}
608
609impl Default for NetBenchResponderBuilder {
610    fn default() -> Self {
611        Self {
612            concurrent_tests: 2,
613            test_duration: Duration::from_secs(30),
614            parallel_streams: 8,
615            chunk_size: DEFAULT_MAX_CHUNK_SIZE,
616            throughput_policy: ThroughputPolicy::Allow,
617        }
618    }
619}
620
621fn start_probe_echo(
622    active: &mut Option<ProbeEchoTask>,
623    session: Arc<dyn NetBenchSession>,
624    test_id: u64,
625    lifetime: Duration,
626    max_requests: u64,
627) -> Result<()> {
628    if let Some(probe) = active {
629        return Err(Error::Protocol(format!(
630            "cannot start test {test_id} while probe test {} is active",
631            probe.test_id
632        )));
633    }
634    *active = Some(ProbeEchoTask::spawn(
635        session,
636        test_id,
637        lifetime,
638        max_requests,
639    ));
640    Ok(())
641}
642
643fn latency_probe_budget(duration_ms: u32, interval_ms: u32) -> Result<u64> {
644    if interval_ms == 0 {
645        return Err(Error::Protocol(
646            "latency probe interval must be at least one millisecond".to_owned(),
647        ));
648    }
649    Ok(u64::from(duration_ms).div_ceil(u64::from(interval_ms)))
650}
651
652fn loss_probe_budget(duration_ms: u32, rate_per_second: u32) -> Result<u64> {
653    if !(1..=MAX_PROBE_RATE_PER_SECOND).contains(&rate_per_second) {
654        return Err(Error::Protocol(format!(
655            "loss probe rate must be within 1..={MAX_PROBE_RATE_PER_SECOND} per second"
656        )));
657    }
658    Ok((u64::from(duration_ms) * u64::from(rate_per_second)).div_ceil(1_000))
659}
660
661fn loaded_probe_budget(duration: Duration) -> u64 {
662    let requests = duration
663        .as_nanos()
664        .div_ceil(LOADED_LATENCY_INTERVAL.as_nanos());
665    u64::try_from(requests).unwrap_or(u64::MAX)
666}
667
668async fn echo_datagrams(
669    session: Arc<dyn NetBenchSession>,
670    test_id: u64,
671    lifetime: Duration,
672    max_requests: u64,
673) -> Result<()> {
674    let deadline = Instant::now() + lifetime;
675    let mut echoed = HashSet::<u64>::new();
676    let mut echoed_count = 0_u64;
677    while echoed_count < max_requests {
678        let bytes = match tokio::time::timeout_at(deadline, session.read_datagram()).await {
679            Ok(result) => result?,
680            Err(_) => break,
681        };
682        let Ok(mut probe) = postcard::from_bytes::<Probe>(&bytes) else {
683            continue;
684        };
685        if probe.magic != PROBE_MAGIC
686            || probe.test_id != test_id
687            || probe.kind != ProbeKind::Request
688            || !echoed.insert(probe.sequence)
689        {
690            continue;
691        }
692        echoed_count += 1;
693        probe.kind = ProbeKind::Response;
694        let payload = postcard::to_allocvec(&probe)?;
695        match tokio::time::timeout_at(deadline, session.send_datagram(payload)).await {
696            Ok(result) => result?,
697            Err(_) => break,
698        }
699    }
700    Ok(())
701}
702
703async fn send_error_best_effort(control_send: &mut SendStream, code: ErrorCode, message: String) {
704    let _ = tokio::time::timeout(
705        THROUGHPUT_CLEANUP_TIMEOUT,
706        write_control(control_send, &ControlMessage::Error { code, message }),
707    )
708    .await;
709}
710
711async fn send_download(
712    streams: Vec<SendStream>,
713    duration: Duration,
714    chunk_size: usize,
715) -> Result<(u64, Duration)> {
716    let total = Arc::new(AtomicU64::new(0));
717    let started = Instant::now();
718    let deadline = started + duration;
719    let chunk = Arc::new(vec![0xA5; chunk_size]);
720    let mut tasks = JoinSet::new();
721
722    for mut stream in streams {
723        let total = Arc::clone(&total);
724        let chunk = Arc::clone(&chunk);
725        tasks.spawn(async move {
726            while Instant::now() < deadline {
727                match tokio::time::timeout_at(deadline, stream.write(&chunk)).await {
728                    Ok(Ok(written)) => {
729                        total.fetch_add(written as u64, Ordering::Relaxed);
730                    }
731                    Ok(Err(Error::FlowStopped)) | Err(_) => break,
732                    Ok(Err(error)) => return Err(error),
733                }
734            }
735            stream.cancel();
736            Result::<()>::Ok(())
737        });
738    }
739
740    while let Some(result) = tasks.join_next().await {
741        result.map_err(Error::network)??;
742    }
743    tokio::time::sleep_until(deadline).await;
744    Ok((total.load(Ordering::Relaxed), duration))
745}
746
747async fn open_download_streams(connection: Connection, streams: u16) -> Result<Vec<SendStream>> {
748    let mut opened = Vec::with_capacity(usize::from(streams));
749    for _ in 0..streams {
750        let mut stream = connection.open_uni().await?;
751        stream.write_all(&[DOWNLOAD_STREAM_MAGIC]).await?;
752        opened.push(stream);
753    }
754    Ok(opened)
755}
756
757async fn expect_test_ready(recv: &mut RecvStream, expected_test_id: u64) -> Result<()> {
758    match read_control(recv).await? {
759        ControlMessage::TestReady { test_id } if test_id == expected_test_id => Ok(()),
760        ControlMessage::TestReady { test_id } => Err(Error::Protocol(format!(
761            "received readiness for test {test_id}, expected {expected_test_id}"
762        ))),
763        message => Err(Error::Protocol(format!(
764            "expected TestReady, received {message:?}"
765        ))),
766    }
767}
768
769async fn accept_upload_streams(connection: Connection, streams: u16) -> Result<Vec<RecvStream>> {
770    let mut accepted = Vec::with_capacity(usize::from(streams));
771    for _ in 0..streams {
772        let mut stream = connection.accept_uni().await?;
773        let mut magic = [0_u8; 1];
774        stream.read_exact(&mut magic).await?;
775        if magic[0] != UPLOAD_STREAM_MAGIC {
776            return Err(Error::Protocol(
777                "upload stream has an invalid pre-measurement header".to_owned(),
778            ));
779        }
780        accepted.push(stream);
781    }
782    Ok(accepted)
783}
784
785async fn receive_upload(
786    streams: Vec<RecvStream>,
787    control_send: &mut SendStream,
788    test_id: u64,
789    duration: Duration,
790) -> Result<(u64, Duration)> {
791    let total = Arc::new(AtomicU64::new(0));
792    let mut tasks = JoinSet::new();
793
794    let started = Instant::now();
795    let deadline = started + duration;
796    for mut stream in streams {
797        let total = Arc::clone(&total);
798        tasks.spawn(async move {
799            let mut buffer = vec![0_u8; 64 * 1024];
800            while Instant::now() < deadline {
801                match tokio::time::timeout_at(deadline, stream.read(&mut buffer)).await {
802                    Ok(Ok(read)) if read > 0 && Instant::now() <= deadline => {
803                        total.fetch_add(read as u64, Ordering::Relaxed);
804                    }
805                    Ok(Ok(_)) | Err(_) => break,
806                    Ok(Err(error)) => return Err(error),
807                }
808            }
809            stream.cancel();
810            Result::<()>::Ok(())
811        });
812    }
813    let mut ticker = tokio::time::interval(THROUGHPUT_SAMPLE_INTERVAL);
814    ticker.set_missed_tick_behavior(MissedTickBehavior::Delay);
815    ticker.tick().await;
816    while !tasks.is_empty() {
817        tokio::select! {
818            result = tasks.join_next() => {
819                if let Some(result) = result {
820                    result.map_err(Error::network)??;
821                }
822            }
823            _ = ticker.tick() => {
824                tokio::time::timeout_at(
825                    deadline + THROUGHPUT_CLEANUP_TIMEOUT,
826                    write_control(
827                        control_send,
828                        &ControlMessage::ThroughputProgress {
829                            test_id,
830                            received_bytes: total.load(Ordering::Relaxed),
831                            duration_ns: duration_ns(started.elapsed().min(duration)),
832                        },
833                    ),
834                )
835                .await
836                .map_err(|_| Error::Timeout {
837                    stage: "upload progress",
838                })??;
839            }
840        }
841    }
842    tokio::time::sleep_until(deadline).await;
843    Ok((total.load(Ordering::Relaxed), duration))
844}
845
846fn duration_ms(duration: Duration) -> u32 {
847    u32::try_from(duration.as_millis()).unwrap_or(u32::MAX)
848}
849
850fn duration_ns(duration: Duration) -> u64 {
851    u64::try_from(duration.as_nanos()).unwrap_or(u64::MAX)
852}
853
854#[cfg(test)]
855mod tests {
856    use std::{collections::VecDeque, future::pending, sync::Mutex};
857
858    use async_trait::async_trait;
859
860    use super::*;
861    use crate::{NetBenchBidirectionalStream, NetBenchTelemetry};
862
863    struct PeerStoppedSend;
864
865    #[async_trait]
866    impl NetBenchSendStream for PeerStoppedSend {
867        async fn write_all(&mut self, _bytes: &[u8]) -> Result<()> {
868            Err(Error::FlowStopped)
869        }
870
871        fn finish(&mut self) -> Result<()> {
872            Ok(())
873        }
874
875        fn cancel(&mut self) {}
876    }
877
878    #[derive(Default)]
879    struct ProbeSession {
880        datagrams: Mutex<VecDeque<Vec<u8>>>,
881        sent: Mutex<Vec<Vec<u8>>>,
882    }
883
884    #[async_trait]
885    impl NetBenchSession for ProbeSession {
886        fn remote_peer_id(&self) -> String {
887            "peer".to_owned()
888        }
889
890        fn telemetry(&self) -> NetBenchTelemetry {
891            NetBenchTelemetry::default()
892        }
893
894        fn max_datagram_size(&self) -> Option<usize> {
895            Some(1_200)
896        }
897
898        async fn open_bi(&self) -> Result<Box<dyn NetBenchBidirectionalStream>> {
899            Err(Error::Protocol("stream operation is unused".to_owned()))
900        }
901
902        async fn accept_bi(&self) -> Result<Box<dyn NetBenchBidirectionalStream>> {
903            Err(Error::Protocol("stream operation is unused".to_owned()))
904        }
905
906        async fn send_datagram(&self, bytes: Vec<u8>) -> Result<()> {
907            lock_unpoisoned(&self.sent).push(bytes);
908            Ok(())
909        }
910
911        async fn read_datagram(&self) -> Result<Vec<u8>> {
912            if let Some(bytes) = lock_unpoisoned(&self.datagrams).pop_front() {
913                return Ok(bytes);
914            }
915            pending().await
916        }
917    }
918
919    struct SinkSend;
920
921    #[async_trait]
922    impl NetBenchSendStream for SinkSend {
923        async fn write_all(&mut self, _bytes: &[u8]) -> Result<()> {
924            Ok(())
925        }
926
927        fn finish(&mut self) -> Result<()> {
928            Ok(())
929        }
930
931        fn cancel(&mut self) {}
932    }
933
934    struct ScriptedReceive {
935        bytes: VecDeque<u8>,
936        terminal: Option<Error>,
937    }
938
939    #[async_trait]
940    impl NetBenchReceiveStream for ScriptedReceive {
941        async fn read(&mut self, bytes: &mut [u8]) -> Result<usize> {
942            if self.bytes.is_empty() {
943                return Err(self.terminal.take().unwrap_or_else(|| {
944                    std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "script exhausted")
945                        .into()
946                }));
947            }
948            let count = bytes.len().min(self.bytes.len());
949            for byte in &mut bytes[..count] {
950                *byte = self.bytes.pop_front().expect("length checked");
951            }
952            Ok(count)
953        }
954
955        async fn read_exact(&mut self, bytes: &mut [u8]) -> Result<()> {
956            for byte in bytes {
957                let Some(next) = self.bytes.pop_front() else {
958                    return Err(self.terminal.take().unwrap_or_else(|| {
959                        std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "script exhausted")
960                            .into()
961                    }));
962                };
963                *byte = next;
964            }
965            Ok(())
966        }
967
968        fn cancel(&mut self) {}
969    }
970
971    fn lock_unpoisoned<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
972        mutex
973            .lock()
974            .unwrap_or_else(std::sync::PoisonError::into_inner)
975    }
976
977    fn control_frame(message: &ControlMessage) -> VecDeque<u8> {
978        let payload = postcard::to_allocvec(message).unwrap();
979        let mut frame = VecDeque::from(u32::try_from(payload.len()).unwrap().to_be_bytes());
980        frame.extend(payload);
981        frame
982    }
983
984    fn probe_bytes(magic: [u8; 4], test_id: u64, sequence: u64, kind: ProbeKind) -> Vec<u8> {
985        postcard::to_allocvec(&Probe {
986            magic,
987            test_id,
988            sequence,
989            kind,
990        })
991        .unwrap()
992    }
993
994    #[tokio::test]
995    async fn receiver_deadline_stop_is_a_clean_download_terminal() {
996        let result = send_download(
997            vec![Box::new(PeerStoppedSend)],
998            Duration::from_millis(1),
999            1024,
1000        )
1001        .await
1002        .unwrap();
1003        assert_eq!(result.0, 0);
1004    }
1005
1006    #[test]
1007    fn probe_budgets_match_the_declared_cadence() {
1008        assert_eq!(latency_probe_budget(2_000, 100).unwrap(), 20);
1009        assert_eq!(loss_probe_budget(2_000, 100).unwrap(), 200);
1010        assert!(latency_probe_budget(2_000, 0).is_err());
1011        assert!(loss_probe_budget(2_000, MAX_PROBE_RATE_PER_SECOND + 1).is_err());
1012    }
1013
1014    #[tokio::test]
1015    async fn probe_echo_is_scoped_deduplicated_and_bounded() {
1016        let session = Arc::new(ProbeSession {
1017            datagrams: Mutex::new(VecDeque::from([
1018                probe_bytes(*b"NOPE", 42, 0, ProbeKind::Request),
1019                probe_bytes(PROBE_MAGIC, 42, 0, ProbeKind::Request),
1020                probe_bytes(PROBE_MAGIC, 42, 0, ProbeKind::Request),
1021                probe_bytes(PROBE_MAGIC, 7, 1, ProbeKind::Request),
1022                probe_bytes(PROBE_MAGIC, 42, 1, ProbeKind::Response),
1023                probe_bytes(PROBE_MAGIC, 42, 1, ProbeKind::Request),
1024            ])),
1025            sent: Mutex::new(Vec::new()),
1026        });
1027
1028        echo_datagrams(
1029            Arc::clone(&session) as Arc<dyn NetBenchSession>,
1030            42,
1031            Duration::from_secs(1),
1032            2,
1033        )
1034        .await
1035        .unwrap();
1036
1037        let sent = lock_unpoisoned(&session.sent);
1038        assert_eq!(sent.len(), 2);
1039        for (sequence, bytes) in sent.iter().enumerate() {
1040            let probe: Probe = postcard::from_bytes(bytes).unwrap();
1041            assert_eq!(probe.magic, PROBE_MAGIC);
1042            assert_eq!(probe.test_id, 42);
1043            assert_eq!(probe.sequence, sequence as u64);
1044            assert_eq!(probe.kind, ProbeKind::Response);
1045        }
1046    }
1047
1048    #[tokio::test]
1049    async fn responder_propagates_control_session_errors() {
1050        let hello = ControlMessage::ClientHello {
1051            protocol_versions: vec![PROTOCOL_VERSION],
1052            capabilities: Capabilities::default(),
1053        };
1054        let flow = NetBenchFlow::new(
1055            Arc::new(ProbeSession::default()),
1056            Box::new(SinkSend),
1057            Box::new(ScriptedReceive {
1058                bytes: control_frame(&hello),
1059                terminal: Some(Error::Network("dispatcher closed".to_owned())),
1060            }),
1061        );
1062
1063        let error = NetBenchResponder::default().serve(flow).await.unwrap_err();
1064        assert!(matches!(
1065            error,
1066            Error::Network(message) if message == "dispatcher closed"
1067        ));
1068    }
1069}