Skip to main content

irtt_client/
session.rs

1use std::{fmt, time::Instant};
2
3use irtt_proto::{Clock, Params, ReceivedStats, StampAt, PROTOCOL_VERSION};
4
5use crate::{
6    config::{NegotiationPolicy, MAX_DSCP_CODEPOINT},
7    error::ClientError,
8    probe::{CompletedSet, PendingMap, TimedOutMap},
9};
10
11/// Protocol parameters accepted for a session.
12///
13/// `params` contains the server-returned values that the client will use for
14/// echo packets. `restrictions` records accepted differences from the request
15/// when loose negotiation is enabled.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct NegotiatedParams {
18    /// Server-returned protocol parameters used for the session.
19    pub params: Params,
20    /// Accepted server restrictions or parameter changes.
21    pub restrictions: Vec<NegotiationRestriction>,
22}
23
24/// A server-side restriction applied during session parameter negotiation.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum NegotiationRestriction {
27    /// Run duration was reduced.
28    ///
29    /// A requested duration of `0` means the client requested continuous mode.
30    /// When `requested_ns == 0` and `negotiated_ns > 0`, the server limited
31    /// that continuous request to a finite duration.
32    DurationReduced {
33        requested_ns: i64,
34        negotiated_ns: i64,
35    },
36    /// Probe interval was increased.
37    IntervalIncreased {
38        requested_ns: i64,
39        negotiated_ns: i64,
40    },
41    /// Probe interval was reduced.
42    IntervalReduced {
43        requested_ns: i64,
44        negotiated_ns: i64,
45    },
46    /// Packet length was reduced.
47    LengthReduced { requested: i64, negotiated: i64 },
48    /// Returned received-statistics mode differs from the request.
49    ReceivedStatsChanged {
50        requested: ReceivedStats,
51        negotiated: ReceivedStats,
52    },
53    /// Returned timestamp placement differs from the request.
54    StampAtChanged {
55        requested: StampAt,
56        negotiated: StampAt,
57    },
58    /// Returned clock source differs from the request.
59    ClockChanged { requested: Clock, negotiated: Clock },
60    /// Returned DSCP codepoint differs from the request.
61    DscpChanged { requested: i64, negotiated: i64 },
62    /// Returned server payload fill behavior differs from the request.
63    ServerFillChanged,
64}
65
66impl NegotiationRestriction {
67    /// Return a human-readable description of this negotiated restriction.
68    pub fn message(&self) -> String {
69        self.to_string()
70    }
71}
72
73impl fmt::Display for NegotiationRestriction {
74    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75        match self {
76            Self::DurationReduced {
77                requested_ns: 0,
78                negotiated_ns,
79            } => {
80                write!(
81                    f,
82                    "server limited continuous duration to {negotiated_ns} ns"
83                )
84            }
85            Self::DurationReduced {
86                requested_ns,
87                negotiated_ns,
88            } => {
89                write!(
90                    f,
91                    "server reduced duration from {requested_ns} ns to {negotiated_ns} ns"
92                )
93            }
94            Self::IntervalIncreased {
95                requested_ns,
96                negotiated_ns,
97            } => {
98                write!(
99                    f,
100                    "server increased interval from {requested_ns} ns to {negotiated_ns} ns"
101                )
102            }
103            Self::IntervalReduced {
104                requested_ns,
105                negotiated_ns,
106            } => {
107                write!(
108                    f,
109                    "server reduced interval from {requested_ns} ns to {negotiated_ns} ns"
110                )
111            }
112            Self::LengthReduced {
113                requested,
114                negotiated,
115            } => {
116                write!(
117                    f,
118                    "server reduced packet length from {requested} bytes to {negotiated} bytes"
119                )
120            }
121            Self::ReceivedStatsChanged {
122                requested,
123                negotiated,
124            } => {
125                write!(
126                    f,
127                    "server changed received-stats from {requested:?} to {negotiated:?}"
128                )
129            }
130            Self::StampAtChanged {
131                requested,
132                negotiated,
133            } => {
134                write!(
135                    f,
136                    "server changed stamp-at from {requested:?} to {negotiated:?}"
137                )
138            }
139            Self::ClockChanged {
140                requested,
141                negotiated,
142            } => {
143                write!(
144                    f,
145                    "server changed clock from {requested:?} to {negotiated:?}"
146                )
147            }
148            Self::DscpChanged {
149                requested,
150                negotiated,
151            } => {
152                write!(f, "server changed DSCP from {requested} to {negotiated}")
153            }
154            Self::ServerFillChanged => write!(f, "server changed payload fill behavior"),
155        }
156    }
157}
158
159#[derive(Debug, Clone, Copy, PartialEq, Eq)]
160pub(crate) enum ClientPhase {
161    Connected,
162    Open { token: u64 },
163    NoTestCompleted,
164    Closed,
165}
166
167#[derive(Debug)]
168pub(crate) struct ActiveSession {
169    pub next_wire_seq: u32,
170    pub highest_received_seq: Option<u32>,
171    pub packets_sent: u64,
172    pub start_mono: Instant,
173    pub end_mono: Option<Instant>,
174    pub next_send_at: Instant,
175    pub pending: PendingMap,
176    pub timed_out: TimedOutMap,
177    pub completed: CompletedSet,
178    pub sending_done: bool,
179}
180
181pub(crate) fn negotiate_params(
182    requested: &Params,
183    returned: Params,
184    policy: NegotiationPolicy,
185) -> Result<NegotiatedParams, ClientError> {
186    if returned.protocol_version != PROTOCOL_VERSION {
187        return Err(ClientError::ProtocolVersionMismatch {
188            requested: PROTOCOL_VERSION,
189            received: returned.protocol_version,
190        });
191    }
192    let mut restrictions = Vec::new();
193
194    validate_duration_restriction(requested.duration_ns, returned.duration_ns)?;
195    if returned.length < 0 {
196        return Err(ClientError::NegotiationRejected {
197            reason: "length must be non-negative".to_owned(),
198        });
199    }
200    if returned.length > requested.length {
201        return Err(ClientError::NegotiationRejected {
202            reason: "length increased".to_owned(),
203        });
204    }
205    if returned.interval_ns <= 0 {
206        return Err(ClientError::NegotiationRejected {
207            reason: "interval must be positive".to_owned(),
208        });
209    }
210    validate_dscp_restriction(returned.dscp)?;
211
212    if returned.duration_ns < requested.duration_ns
213        || (requested.duration_ns == 0 && returned.duration_ns > 0)
214    {
215        record_restriction(
216            policy,
217            &mut restrictions,
218            NegotiationRestriction::DurationReduced {
219                requested_ns: requested.duration_ns,
220                negotiated_ns: returned.duration_ns,
221            },
222        )?;
223    }
224    if returned.interval_ns > requested.interval_ns {
225        record_restriction(
226            policy,
227            &mut restrictions,
228            NegotiationRestriction::IntervalIncreased {
229                requested_ns: requested.interval_ns,
230                negotiated_ns: returned.interval_ns,
231            },
232        )?;
233    }
234    if returned.interval_ns < requested.interval_ns {
235        record_restriction(
236            policy,
237            &mut restrictions,
238            NegotiationRestriction::IntervalReduced {
239                requested_ns: requested.interval_ns,
240                negotiated_ns: returned.interval_ns,
241            },
242        )?;
243    }
244    if returned.length < requested.length {
245        record_restriction(
246            policy,
247            &mut restrictions,
248            NegotiationRestriction::LengthReduced {
249                requested: requested.length,
250                negotiated: returned.length,
251            },
252        )?;
253    }
254    if returned.received_stats != requested.received_stats {
255        record_restriction(
256            policy,
257            &mut restrictions,
258            NegotiationRestriction::ReceivedStatsChanged {
259                requested: requested.received_stats,
260                negotiated: returned.received_stats,
261            },
262        )?;
263    }
264    if returned.stamp_at != requested.stamp_at {
265        record_restriction(
266            policy,
267            &mut restrictions,
268            NegotiationRestriction::StampAtChanged {
269                requested: requested.stamp_at,
270                negotiated: returned.stamp_at,
271            },
272        )?;
273    }
274    if returned.clock != requested.clock {
275        record_restriction(
276            policy,
277            &mut restrictions,
278            NegotiationRestriction::ClockChanged {
279                requested: requested.clock,
280                negotiated: returned.clock,
281            },
282        )?;
283    }
284    if returned.dscp != requested.dscp && returned.dscp != 0 {
285        return Err(ClientError::NegotiationRejected {
286            reason: "server returned unsupported DSCP change".to_owned(),
287        });
288    }
289    if returned.dscp == 0 && requested.dscp != 0 {
290        record_restriction(
291            policy,
292            &mut restrictions,
293            NegotiationRestriction::DscpChanged {
294                requested: requested.dscp,
295                negotiated: returned.dscp,
296            },
297        )?;
298    }
299    if returned.server_fill != requested.server_fill {
300        record_restriction(
301            policy,
302            &mut restrictions,
303            NegotiationRestriction::ServerFillChanged,
304        )?;
305    }
306
307    Ok(NegotiatedParams {
308        params: returned,
309        restrictions,
310    })
311}
312
313fn validate_duration_restriction(requested: i64, returned: i64) -> Result<(), ClientError> {
314    if returned < 0 {
315        return Err(ClientError::NegotiationRejected {
316            reason: "duration must be non-negative".to_owned(),
317        });
318    }
319
320    if requested > 0 && returned == 0 {
321        return Err(ClientError::NegotiationRejected {
322            reason: "server returned continuous duration for finite request".to_owned(),
323        });
324    }
325
326    if requested > 0 && returned > requested {
327        return Err(ClientError::NegotiationRejected {
328            reason: "duration increased".to_owned(),
329        });
330    }
331
332    Ok(())
333}
334
335fn validate_dscp_restriction(returned: i64) -> Result<(), ClientError> {
336    if !(0..=i64::from(MAX_DSCP_CODEPOINT)).contains(&returned) {
337        return Err(ClientError::NegotiationRejected {
338            reason: format!("dscp must be in range 0..={MAX_DSCP_CODEPOINT}"),
339        });
340    }
341
342    Ok(())
343}
344
345fn record_restriction(
346    policy: NegotiationPolicy,
347    restrictions: &mut Vec<NegotiationRestriction>,
348    restriction: NegotiationRestriction,
349) -> Result<(), ClientError> {
350    if policy == NegotiationPolicy::Strict {
351        return Err(ClientError::NegotiationRejected {
352            reason: restriction.message(),
353        });
354    }
355
356    restrictions.push(restriction);
357    Ok(())
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363    use irtt_proto::{Clock, ReceivedStats, ServerFill, StampAt};
364
365    fn default_params() -> Params {
366        Params {
367            protocol_version: PROTOCOL_VERSION,
368            duration_ns: 3_000_000_000,
369            interval_ns: 1_000_000_000,
370            length: 256,
371            received_stats: ReceivedStats::Both,
372            stamp_at: StampAt::Both,
373            clock: Clock::Both,
374            dscp: 46,
375            server_fill: Some(ServerFill {
376                value: "rand".to_owned(),
377            }),
378        }
379    }
380
381    fn assert_rejected(requested: &Params, returned: &Params, policy: NegotiationPolicy) {
382        assert!(matches!(
383            negotiate_params(requested, returned.clone(), policy),
384            Err(ClientError::NegotiationRejected { .. })
385        ));
386    }
387
388    fn assert_negotiates(
389        requested: &Params,
390        returned: &Params,
391        policy: NegotiationPolicy,
392    ) -> NegotiatedParams {
393        negotiate_params(requested, returned.clone(), policy)
394            .unwrap_or_else(|err| panic!("expected negotiation success, got {err:?}"))
395    }
396
397    fn rejection_reason(
398        requested: &Params,
399        returned: &Params,
400        policy: NegotiationPolicy,
401    ) -> String {
402        match negotiate_params(requested, returned.clone(), policy) {
403            Err(ClientError::NegotiationRejected { reason }) => reason,
404            other => panic!("expected negotiation rejection, got {other:?}"),
405        }
406    }
407
408    #[test]
409    fn strict_rejects_changed_negotiated_fields() {
410        let requested = default_params();
411
412        let mut returned = requested.clone();
413        returned.length = 128;
414        assert_eq!(
415            rejection_reason(&requested, &returned, NegotiationPolicy::Strict),
416            NegotiationRestriction::LengthReduced {
417                requested: requested.length,
418                negotiated: returned.length,
419            }
420            .message()
421        );
422
423        let mut returned = requested.clone();
424        returned.dscp = 0;
425        assert_eq!(
426            rejection_reason(&requested, &returned, NegotiationPolicy::Strict),
427            NegotiationRestriction::DscpChanged {
428                requested: requested.dscp,
429                negotiated: returned.dscp,
430            }
431            .message()
432        );
433
434        let mut returned = requested.clone();
435        returned.received_stats = ReceivedStats::Count;
436        assert_eq!(
437            rejection_reason(&requested, &returned, NegotiationPolicy::Strict),
438            NegotiationRestriction::ReceivedStatsChanged {
439                requested: requested.received_stats,
440                negotiated: returned.received_stats,
441            }
442            .message()
443        );
444
445        let mut returned = requested.clone();
446        returned.stamp_at = StampAt::Midpoint;
447        assert_eq!(
448            rejection_reason(&requested, &returned, NegotiationPolicy::Strict),
449            NegotiationRestriction::StampAtChanged {
450                requested: requested.stamp_at,
451                negotiated: returned.stamp_at,
452            }
453            .message()
454        );
455
456        let mut returned = requested.clone();
457        returned.clock = Clock::Wall;
458        assert_eq!(
459            rejection_reason(&requested, &returned, NegotiationPolicy::Strict),
460            NegotiationRestriction::ClockChanged {
461                requested: requested.clock,
462                negotiated: returned.clock,
463            }
464            .message()
465        );
466
467        let mut returned = requested.clone();
468        returned.server_fill = None;
469        assert_eq!(
470            rejection_reason(&requested, &returned, NegotiationPolicy::Strict),
471            NegotiationRestriction::ServerFillChanged.message()
472        );
473    }
474
475    #[test]
476    fn loose_duration_negotiation_uses_run_duration_semantics() {
477        let requested = default_params();
478
479        let mut returned = requested.clone();
480        returned.duration_ns = requested.duration_ns / 2;
481        let negotiated = assert_negotiates(&requested, &returned, NegotiationPolicy::Loose);
482        assert_eq!(
483            negotiated.restrictions,
484            vec![NegotiationRestriction::DurationReduced {
485                requested_ns: requested.duration_ns,
486                negotiated_ns: returned.duration_ns,
487            }]
488        );
489
490        let mut returned = requested.clone();
491        returned.duration_ns = requested.duration_ns + 1;
492        assert_eq!(
493            rejection_reason(&requested, &returned, NegotiationPolicy::Loose),
494            "duration increased"
495        );
496
497        let mut returned = requested.clone();
498        returned.duration_ns = 0;
499        assert_eq!(
500            rejection_reason(&requested, &returned, NegotiationPolicy::Loose),
501            "server returned continuous duration for finite request"
502        );
503
504        let mut continuous_requested = requested.clone();
505        continuous_requested.duration_ns = 0;
506        let mut finite_returned = continuous_requested.clone();
507        finite_returned.duration_ns = 1_000_000_000;
508        let negotiated = assert_negotiates(
509            &continuous_requested,
510            &finite_returned,
511            NegotiationPolicy::Loose,
512        );
513        assert_eq!(
514            negotiated.restrictions,
515            vec![NegotiationRestriction::DurationReduced {
516                requested_ns: 0,
517                negotiated_ns: finite_returned.duration_ns,
518            }]
519        );
520
521        assert_rejected(
522            &continuous_requested,
523            &finite_returned,
524            NegotiationPolicy::Strict,
525        );
526
527        assert!(negotiate_params(
528            &continuous_requested,
529            continuous_requested.clone(),
530            NegotiationPolicy::Strict
531        )
532        .is_ok());
533    }
534}