Skip to main content

irtt_client/session/
negotiation.rs

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