Skip to main content

irtt_client/
client.rs

1use std::{
2    io,
3    net::{SocketAddr, UdpSocket},
4    time::{Duration, Instant},
5};
6
7use crate::{
8    config::{ClientConfig, RecvBudget},
9    error::ClientError,
10    event::{ClientEvent, OpenOutcome},
11    receive::{drain_tx_timestamps, recv_datagram, try_enable_tx_timestamping, ReceivedDatagram},
12    session::machine::{
13        recv_buffer_size, OpenDatagramDisposition, PreparedOpenAcceptance, SessionMachine,
14        MAX_OPEN_PACKET_SIZE,
15    },
16    socket::{connect_udp_socket, resolve_remote},
17    socket_options::{apply_traffic_class_to_socket, clear_dscp_on_socket},
18    timing::ClientTimestamp,
19    SendProbeError, SendReceipt,
20};
21
22#[derive(Debug)]
23struct PreparedClientOpen {
24    machine: PreparedOpenAcceptance,
25    recv_buffer_len: Option<usize>,
26    negotiated_traffic_class: Option<u8>,
27    previous_traffic_class: Option<u8>,
28    post_open_recv_timeout: Option<Duration>,
29}
30
31#[derive(Debug)]
32struct PreparedClientOpenFailure {
33    primary: ClientError,
34    machine: PreparedOpenAcceptance,
35}
36
37/// Low-level synchronous IRTT client.
38///
39/// `Client` exposes the protocol steps directly: connect a UDP socket, open a
40/// session, send probes, receive replies, poll timeouts, and close. Callers
41/// that do not need to own this loop can use the unified managed API in
42/// [`crate::managed`] when the `tokio` feature is enabled.
43///
44/// # Example
45///
46/// `Client` is the runtime-free adapter. The caller drives receives and timeout
47/// polling between sends; see the standalone example for a complete loop.
48/// This example type-checks without contacting a server:
49///
50/// ```no_run
51/// use irtt_client::{Client, ClientConfig};
52///
53/// # fn run() -> Result<(), Box<dyn std::error::Error>> {
54/// let mut client = Client::connect("127.0.0.1:2112", ClientConfig::default())?;
55/// let outcome = client.open()?;
56/// println!("opened: {outcome:?}");
57/// let sent = client.send_probe()?;
58/// println!("sent: {sent:?}");
59/// let received = client.recv_once()?;
60/// println!("received: {received:?}");
61/// let timed_out = client.poll_timeouts()?;
62/// println!("timeouts: {timed_out:?}");
63/// let closed = client.close()?;
64/// println!("closed: {closed:?}");
65/// # Ok(())
66/// # }
67/// ```
68#[derive(Debug)]
69pub struct Client {
70    runtime: SessionMachine,
71    socket: UdpSocket,
72    remote: SocketAddr,
73    recv_buffer: Vec<u8>,
74    recv_timeout: Option<Duration>,
75    applied_traffic_class: Option<u8>,
76    /// Whether the socket's `SO_TIMESTAMPING` flags carry `TX_SOFTWARE` +
77    /// `OPT_ID` + `OPT_TSONLY`. Always `false` off Linux, without the
78    /// `ancillary` feature, or before the best-effort post-Open upgrade;
79    /// tracked here (adapter-owned capability, not protocol state) so
80    /// unnecessary error-queue drains are skipped once it is known there is
81    /// nothing to drain.
82    tx_timestamping_enabled: bool,
83}
84
85impl Client {
86    /// Resolve the endpoint and create a connected UDP socket.
87    ///
88    /// This validates local configuration and prepares the open request, but it
89    /// does not contact the server. Call [`open`](Self::open) to perform the
90    /// IRTT open exchange.
91    ///
92    /// The endpoint accepts a name or address; an omitted port defaults to 2112.
93    /// IPv6 literals may be bracketed or unbracketed with the default port.
94    /// The reusable config contains no endpoint identity.
95    pub fn connect(endpoint: impl AsRef<str>, config: ClientConfig) -> Result<Self, ClientError> {
96        config.open.validate()?;
97        let remote = resolve_remote(endpoint.as_ref(), config.address_family)?;
98        let runtime = SessionMachine::new(config.clone(), remote)?;
99        let socket = connect_udp_socket(&config.socket, remote, config.address_family)?;
100
101        Ok(Self {
102            runtime,
103            socket,
104            remote,
105            recv_buffer: vec![0_u8; recv_buffer_size(false, None)?],
106            recv_timeout: None,
107            applied_traffic_class: None,
108            tx_timestamping_enabled: false,
109        })
110    }
111
112    /// Set the blocking receive timeout used outside open attempts.
113    ///
114    /// `None` (the default) lets `recv_once()` block indefinitely. A nonzero
115    /// timeout bounds one logical receive, including retries after EINTR.
116    /// `recv_available()` retains its bounded drain behavior. Open attempts
117    /// use only [`OpenPolicy::timeouts`](crate::OpenPolicy::timeouts) and
118    /// restore this setting on success or failure.
119    ///
120    /// A zero duration is rejected by the socket; the previous setting is
121    /// retained when applying the new timeout fails.
122    pub fn set_recv_timeout(&mut self, timeout: Option<Duration>) -> Result<(), ClientError> {
123        self.socket.set_read_timeout(timeout)?;
124        self.recv_timeout = timeout;
125        Ok(())
126    }
127
128    /// Perform the IRTT open exchange.
129    ///
130    /// On success, returns the negotiated open outcome and transitions the
131    /// client into either an open probe session or completed no-test state.
132    /// Open attempts use [`OpenPolicy::timeouts`](crate::OpenPolicy::timeouts). Malformed or
133    /// unrelated datagrams are ignored until the current attempt's absolute
134    /// deadline, so one attempt may consume several datagrams without
135    /// retransmitting. Silence or ignored traffic eventually produces
136    /// [`ClientError::OpenTimeout`]. A structurally recognizable malformed or
137    /// incompatible Open reply from the connected peer is terminal; when HMAC
138    /// is configured, that recognition additionally requires authentication.
139    ///
140    /// When a trusted reply allocates a token but later negotiation or socket
141    /// preparation fails, the client sends a best-effort cleanup close and
142    /// preserves the original failure. Failed opening never leaves the session
143    /// machine open.
144    pub fn open(&mut self) -> Result<OpenOutcome, ClientError> {
145        let result = self.open_transaction();
146        if result.is_err() {
147            let _ = self.socket.set_read_timeout(self.recv_timeout);
148        }
149        result
150    }
151
152    /// Send a close request and emit a [`ClientEvent::SessionClosed`] event.
153    ///
154    /// The close event means the client has sent its close packet and stopped
155    /// tracking the session locally; it is not a server acknowledgement. If the
156    /// send fails, the negotiated DSCP is restored best-effort and the session
157    /// remains open.
158    pub fn close(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
159        let prepared = self.runtime.prepare_close()?;
160        let mut events = Vec::new();
161
162        events
163            .try_reserve(1)
164            .map_err(|source| ClientError::AllocationFailed {
165                operation: "close event result",
166                source,
167            })?;
168        let previous_traffic_class = self.applied_traffic_class;
169        let expected_bytes = prepared.bytes.len();
170
171        self.clear_close_dscp()?;
172        let bytes = match self.socket.send(prepared.bytes) {
173            Ok(bytes) => bytes,
174            Err(err) => {
175                self.restore_dscp_best_effort(previous_traffic_class);
176                return Err(ClientError::Socket(err));
177            }
178        };
179        let sent_at = ClientTimestamp::now();
180
181        let event = self.runtime.commit_local_close(prepared.commit, sent_at);
182
183        self.applied_traffic_class = None;
184        validate_datagram_length(expected_bytes, bytes)?;
185        events.push(event);
186        Ok(events)
187    }
188
189    /// Return the local timeout used to classify pending probes as lost.
190    pub fn probe_timeout(&self) -> Duration {
191        self.runtime.probe_timeout()
192    }
193
194    /// Send one echo probe now. The caller owns pacing and run duration.
195    ///
196    /// The negotiated interval and duration do not gate low-level sends.
197    /// A receipt means socket acceptance and infallible session commitment have
198    /// completed. Failures before acceptance return [`SendProbeError::NotCommitted`];
199    /// later validation or TX processing failures preserve the receipt in
200    /// [`SendProbeError::AfterCommit`].
201    pub fn send_probe(&mut self) -> Result<SendReceipt, SendProbeError> {
202        let remote = self.remote;
203        let (runtime, socket) = (&mut self.runtime, &self.socket);
204        let prepared = runtime
205            .prepare_probe()
206            .map_err(SendProbeError::NotCommitted)?;
207        let machine_preflight = runtime
208            .preflight_probe_commit(&prepared)
209            .map_err(SendProbeError::NotCommitted)?;
210        let expected_bytes = prepared.bytes.len();
211
212        // All fallible probe-commit preflight (timeout deadline arithmetic,
213        // capacity/collision checks already done above) is finalized from
214        // this PRIVATE pre-send anchor, before the socket send. It is not
215        // the public measurement `sent_at` captured further below.
216        let send_anchor = ClientTimestamp::now();
217        let machine_commit = runtime
218            .finalize_probe_commit(machine_preflight, send_anchor)
219            .map_err(SendProbeError::NotCommitted)?;
220        let send_call_start = Instant::now();
221
222        let bytes = match socket.send(&prepared.bytes) {
223            Ok(bytes) => bytes,
224            Err(error) => {
225                runtime.invalidate_kernel_tx_correlation();
226                return Err(SendProbeError::NotCommitted(ClientError::Socket(error)));
227            }
228        };
229        let send_finished_at = Instant::now();
230
231        // The public measurement timestamp: paired wall/monotonic sample
232        // captured immediately after the successful socket send completed.
233        // This, not `send_anchor` above, is the RTT/OWD-fallback/timer_error
234        // endpoint. The infallible machine commit below never fails.
235        let sent_at = ClientTimestamp::now();
236        let sent = runtime.commit_probe_sent(machine_commit, sent_at, bytes);
237        let send_call = send_finished_at.saturating_duration_since(send_call_start);
238        let receipt = SendReceipt {
239            seq: sent.seq,
240            remote,
241            sent_at: sent.sent_at,
242            bytes: sent.bytes,
243            send_call,
244        };
245        validate_datagram_length(expected_bytes, bytes).map_err(|source| {
246            SendProbeError::AfterCommit {
247                receipt,
248                source: Box::new(source),
249            }
250        })?;
251        self.drain_tx_timestamps()
252            .map_err(|source| SendProbeError::AfterCommit {
253                receipt,
254                source: Box::new(source),
255            })?;
256
257        Ok(receipt)
258    }
259
260    /// Receive and classify at most one datagram from the socket.
261    ///
262    /// Returns an empty event list when the socket read would block or times
263    /// out. Malformed or unrelated datagrams are reported as warning events.
264    /// Requires an open session; lifecycle errors are returned before socket I/O.
265    pub fn recv_once(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
266        self.runtime.ensure_open()?;
267        self.socket.set_read_timeout(self.recv_timeout)?;
268        self.recv_once_inner()
269    }
270
271    fn recv_once_inner(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
272        let Some(datagram) = self.recv_datagram_retrying_interrupted()? else {
273            return Ok(vec![]);
274        };
275
276        // One final bounded drain before processing the reply, so a TX
277        // timestamp that raced the reply is still associated with its probe
278        // before that probe is looked up and possibly removed below.
279        self.drain_tx_timestamps()?;
280
281        let events = self.runtime.process_received_echo_packet(
282            &self.recv_buffer[..datagram.len],
283            datagram.received_at,
284            datagram.meta,
285        )?;
286        if self.runtime.is_peer_closed() && self.clear_close_dscp().is_ok() {
287            self.applied_traffic_class = None;
288        }
289        Ok(events)
290    }
291
292    /// Receive one datagram, transparently retrying an interrupted syscall
293    /// (`io::ErrorKind::Interrupted`, e.g. `EINTR`) without treating it as a
294    /// genuine socket error, timeout, or empty receive.
295    ///
296    /// One logical receive keeps one timeout budget: when a receive timeout
297    /// is configured, an absolute deadline is computed once here and each
298    /// retry after an interruption restores the socket's remaining time
299    /// against that same deadline, so repeated interruptions cannot extend
300    /// this receive past its configured timeout. An unconfigured timeout
301    /// (`None`) keeps blocking indefinitely after an interruption, matching
302    /// its no-timeout contract. The socket's read timeout is restored to the
303    /// caller-configured value before returning, so a later call in the same
304    /// `recv_available` budget is unaffected by an earlier interruption here.
305    fn recv_datagram_retrying_interrupted(
306        &mut self,
307    ) -> Result<Option<ReceivedDatagram>, ClientError> {
308        let configured_timeout = self.recv_timeout;
309        let deadline = match configured_timeout {
310            Some(timeout) => Some(
311                Instant::now()
312                    .checked_add(timeout)
313                    .ok_or(ClientError::DurationOverflow)?,
314            ),
315            None => None,
316        };
317        let mut timeout_narrowed = false;
318
319        let result = loop {
320            match recv_datagram(&self.socket, &mut self.recv_buffer) {
321                Ok(datagram) => break Ok(Some(datagram)),
322                Err(err)
323                    if matches!(
324                        err.kind(),
325                        io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut
326                    ) =>
327                {
328                    break Ok(None);
329                }
330                Err(err) if err.kind() == io::ErrorKind::Interrupted => {
331                    if let Some(deadline) = deadline {
332                        let Some(remaining) = deadline.checked_duration_since(Instant::now())
333                        else {
334                            break Ok(None);
335                        };
336                        if remaining.is_zero() {
337                            break Ok(None);
338                        }
339                        if let Err(err) = self.socket.set_read_timeout(Some(remaining)) {
340                            break Err(ClientError::Socket(err));
341                        }
342                        timeout_narrowed = true;
343                    }
344                }
345                Err(err) => break Err(ClientError::Socket(err)),
346            }
347        };
348
349        if timeout_narrowed {
350            if let Err(err) = self.socket.set_read_timeout(configured_timeout) {
351                return Err(ClientError::Socket(err));
352            }
353        }
354        result
355    }
356
357    /// Receive and classify datagrams until a receive produces no events or the
358    /// receive budget is exhausted.
359    ///
360    /// Requires an open session; lifecycle errors are returned before socket I/O.
361    pub fn recv_available(&mut self, budget: RecvBudget) -> Result<Vec<ClientEvent>, ClientError> {
362        self.runtime.ensure_open()?;
363        self.socket.set_read_timeout(self.recv_timeout)?;
364        let mut all_events = Vec::new();
365        for _ in 0..budget.max_packets {
366            let events = self.recv_once_inner()?;
367            if events.is_empty() {
368                break;
369            }
370            all_events.extend(events);
371            if self.is_peer_closed() {
372                break;
373            }
374        }
375        Ok(all_events)
376    }
377
378    /// Polls for probes that have timed out as of the current monotonic time.
379    pub fn poll_timeouts(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
380        self.poll_timeouts_at(Instant::now())
381    }
382
383    /// Polls for probes that have timed out as of `now`.
384    ///
385    /// This is useful for callers that drive `Client` from their own event loop and
386    /// want timeout decisions to use the same sampled `Instant` as their scheduling
387    /// logic.
388    ///
389    /// `now` is monotonic time only; wall-clock time is not used for timeout expiry.
390    pub fn poll_timeouts_at(&mut self, now: Instant) -> Result<Vec<ClientEvent>, ClientError> {
391        self.drain_tx_timestamps()?;
392        self.runtime.poll_timeouts_at(now)
393    }
394
395    /// Accepted semantics and exact peer parameters of the currently live session.
396    ///
397    /// Returns `None` before Open, after Close, and after no-test completion.
398    pub fn negotiation(&self) -> Option<&crate::NegotiationResult> {
399        self.runtime.negotiation()
400    }
401
402    /// Earliest timeout deadline among probes still awaiting a reply.
403    pub fn next_probe_timeout_deadline(&self) -> Option<Instant> {
404        self.runtime.next_probe_timeout_deadline()
405    }
406
407    /// Whether any sent probes still await a reply or timeout.
408    pub fn has_pending_probes(&self) -> bool {
409        !self.runtime.pending_is_empty()
410    }
411
412    /// Return whether the session was closed by a peer close-flagged reply.
413    ///
414    /// Direct operations on a closed client still return
415    /// [`ClientError::AlreadyClosed`]. This method lets higher-level run loops
416    /// avoid treating a successfully observed peer close as a local cleanup
417    /// failure.
418    pub fn is_peer_closed(&self) -> bool {
419        self.runtime.is_peer_closed()
420    }
421
422    fn open_transaction(&mut self) -> Result<OpenOutcome, ClientError> {
423        let request = self.runtime.prepare_open_request()?;
424        let mut buf = [0_u8; MAX_OPEN_PACKET_SIZE];
425        let attempt_count = self.runtime.config().open.timeouts.len();
426
427        for attempt in 0..attempt_count {
428            let timeout = self.runtime.config().open.timeouts[attempt];
429            let deadline = Instant::now()
430                .checked_add(timeout)
431                .ok_or(ClientError::DurationOverflow)?;
432            self.socket.set_read_timeout(Some(timeout))?;
433            self.socket.send(&request.bytes)?;
434
435            loop {
436                let now = Instant::now();
437                let Some(remaining) = deadline.checked_duration_since(now) else {
438                    break;
439                };
440                if remaining.is_zero() {
441                    break;
442                }
443                self.socket.set_read_timeout(Some(remaining))?;
444
445                let datagram = match recv_datagram(&self.socket, &mut buf) {
446                    Ok(datagram) => datagram,
447                    // WouldBlock/TimedOut mean the attempt's own deadline
448                    // elapsed; Interrupted (EINTR) means the syscall was
449                    // merely interrupted and carries no timing information of
450                    // its own. Both retry through the same loop, which
451                    // recomputes `remaining` and re-applies it as the read
452                    // timeout on every iteration — so an interruption resumes
453                    // within this same absolute deadline rather than
454                    // resetting or extending it.
455                    Err(err)
456                        if matches!(
457                            err.kind(),
458                            io::ErrorKind::WouldBlock
459                                | io::ErrorKind::TimedOut
460                                | io::ErrorKind::Interrupted
461                        ) =>
462                    {
463                        if Instant::now() >= deadline {
464                            break;
465                        }
466                        continue;
467                    }
468                    Err(err) => return Err(ClientError::Socket(err)),
469                };
470                if datagram.received_at.mono > deadline {
471                    break;
472                }
473
474                let reply = match self.runtime.inspect_open_datagram(&buf[..datagram.len])? {
475                    OpenDatagramDisposition::Ignore => continue,
476                    OpenDatagramDisposition::Trusted(reply) => reply,
477                };
478                let machine = match self
479                    .runtime
480                    .prepare_open_acceptance(reply, datagram.received_at)
481                {
482                    Ok(machine) => machine,
483                    Err(failure) => {
484                        self.send_cleanup_close_best_effort(failure.cleanup_close.as_deref());
485                        return Err(failure.primary);
486                    }
487                };
488                let prepared = match self.prepare_client_open(machine) {
489                    Ok(prepared) => prepared,
490                    Err(failure) => {
491                        self.send_cleanup_close_best_effort(failure.machine.cleanup_close_packet());
492                        return Err(failure.primary);
493                    }
494                };
495                return self.apply_prepared_open(prepared);
496            }
497        }
498
499        Err(ClientError::OpenTimeout)
500    }
501
502    fn prepare_client_open(
503        &self,
504        machine: PreparedOpenAcceptance,
505    ) -> Result<PreparedClientOpen, Box<PreparedClientOpenFailure>> {
506        let Some(negotiated) = machine.normal_negotiated() else {
507            return Ok(PreparedClientOpen {
508                machine,
509                recv_buffer_len: None,
510                negotiated_traffic_class: None,
511                previous_traffic_class: self.applied_traffic_class,
512                post_open_recv_timeout: self.recv_timeout,
513            });
514        };
515        let recv_buffer_len = match recv_buffer_size(self.runtime.has_hmac(), Some(negotiated)) {
516            Ok(size) => size,
517            Err(primary) => return Err(Box::new(PreparedClientOpenFailure { primary, machine })),
518        };
519
520        let negotiated_traffic_class = negotiated.accepted.dscp << 2;
521        Ok(PreparedClientOpen {
522            machine,
523            recv_buffer_len: Some(recv_buffer_len),
524            negotiated_traffic_class: Some(negotiated_traffic_class),
525            previous_traffic_class: self.applied_traffic_class,
526            post_open_recv_timeout: self.recv_timeout,
527        })
528    }
529
530    fn apply_prepared_open(
531        &mut self,
532        prepared: PreparedClientOpen,
533    ) -> Result<OpenOutcome, ClientError> {
534        let PreparedClientOpen {
535            machine,
536            recv_buffer_len,
537            negotiated_traffic_class,
538            previous_traffic_class,
539            post_open_recv_timeout,
540        } = prepared;
541
542        if let (Some(recv_buffer_len), Some(negotiated_traffic_class)) =
543            (recv_buffer_len, negotiated_traffic_class)
544        {
545            let previous_len = self.recv_buffer.len();
546            let additional = recv_buffer_len.saturating_sub(previous_len);
547            if let Err(source) = self.recv_buffer.try_reserve(additional) {
548                self.send_cleanup_close_best_effort(machine.cleanup_close_packet());
549                return Err(ClientError::AllocationFailed {
550                    operation: "negotiated receive buffer",
551                    source,
552                });
553            }
554            self.recv_buffer.resize(recv_buffer_len, 0);
555
556            if let Err(primary) =
557                apply_traffic_class_to_socket(&self.socket, self.remote, negotiated_traffic_class)
558            {
559                self.recv_buffer.truncate(previous_len);
560                self.restore_dscp_best_effort(previous_traffic_class);
561                self.send_cleanup_close_best_effort(machine.cleanup_close_packet());
562                return Err(primary);
563            }
564            if let Err(source) = self.socket.set_read_timeout(post_open_recv_timeout) {
565                let primary = ClientError::ReadTimeoutRestore { source };
566                self.recv_buffer.truncate(previous_len);
567                self.restore_dscp_best_effort(previous_traffic_class);
568                self.send_cleanup_close_best_effort(machine.cleanup_close_packet());
569                return Err(primary);
570            }
571
572            let outcome = self.runtime.commit_open(machine);
573
574            self.applied_traffic_class = Some(negotiated_traffic_class);
575            self.tx_timestamping_enabled = try_enable_tx_timestamping(&self.socket);
576            Ok(outcome)
577        } else {
578            if let Err(source) = self.socket.set_read_timeout(post_open_recv_timeout) {
579                return Err(ClientError::ReadTimeoutRestore { source });
580            }
581            let outcome = self.runtime.commit_open(machine);
582
583            self.applied_traffic_class = None;
584            self.tx_timestamping_enabled = try_enable_tx_timestamping(&self.socket);
585            Ok(outcome)
586        }
587    }
588
589    /// Best-effort, bounded, nonblocking drain of the socket's
590    /// `MSG_ERRQUEUE`. A no-op unless [`Self::apply_prepared_open`]
591    /// successfully upgraded the socket to TX timestamping. Any TX
592    /// timestamp found is handed to the session machine to attach to its
593    /// matching probe; a genuine socket/network error found on the queue is
594    /// surfaced through the normal socket-error path.
595    fn drain_tx_timestamps(&mut self) -> Result<(), ClientError> {
596        if !self.tx_timestamping_enabled {
597            return Ok(());
598        }
599        let runtime = &mut self.runtime;
600        drain_tx_timestamps(&self.socket, |id, timestamp| {
601            runtime.record_kernel_tx_timestamp(id, timestamp);
602        })
603        .map_err(ClientError::Socket)
604    }
605
606    fn restore_dscp_best_effort(&self, previous_traffic_class: Option<u8>) {
607        let _ = match previous_traffic_class {
608            Some(traffic_class) => {
609                apply_traffic_class_to_socket(&self.socket, self.remote, traffic_class)
610            }
611            None => clear_dscp_on_socket(&self.socket, self.remote),
612        };
613    }
614
615    fn clear_close_dscp(&self) -> Result<(), ClientError> {
616        clear_dscp_on_socket(&self.socket, self.remote)
617    }
618
619    fn send_cleanup_close_best_effort(&self, packet: Option<&[u8]>) {
620        if let Some(packet) = packet {
621            let _ = self.socket.send(packet);
622        }
623    }
624}
625
626pub(crate) fn validate_datagram_length(expected: usize, actual: usize) -> Result<(), ClientError> {
627    if actual == expected {
628        Ok(())
629    } else {
630        Err(ClientError::DatagramLengthMismatch { expected, actual })
631    }
632}