1use crate::{Backoff, Error, QuicBackend, Reconnect};
2#[cfg(feature = "websocket")]
3use std::future::Future;
4use std::net;
5use url::Url;
6
7const DEFAULT_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
8
9#[derive(Clone, Debug, clap::Parser, serde::Serialize, serde::Deserialize)]
11#[serde(deny_unknown_fields, default)]
12#[non_exhaustive]
13pub struct ClientConfig {
14 #[serde(skip_serializing_if = "Option::is_none")]
22 #[arg(id = "client-connect", long = "client-connect", env = "MOQ_CLIENT_CONNECT")]
23 pub connect: Option<Url>,
24
25 #[arg(
27 id = "client-bind",
28 long = "client-bind",
29 default_value = "[::]:0",
30 env = "MOQ_CLIENT_BIND"
31 )]
32 pub bind: net::SocketAddr,
33
34 #[arg(id = "client-backend", long = "client-backend", env = "MOQ_CLIENT_BACKEND")]
37 pub backend: Option<QuicBackend>,
38
39 #[serde(default, skip_serializing_if = "Option::is_none", with = "humantime_serde::option")]
49 #[arg(
50 id = "client-failover-delay",
51 long = "client-failover-delay",
52 env = "MOQ_CLIENT_FAILOVER_DELAY",
53 value_parser = humantime::parse_duration,
54 )]
55 pub failover_delay: Option<std::time::Duration>,
56
57 #[arg(
67 id = "client-connect-timeout",
68 long = "client-connect-timeout",
69 env = "MOQ_CLIENT_CONNECT_TIMEOUT",
70 value_parser = humantime::parse_duration,
71 )]
72 #[serde(default, skip_serializing_if = "Option::is_none", with = "humantime_serde::option")]
73 pub timeout: Option<std::time::Duration>,
74
75 #[command(flatten)]
77 #[serde(default)]
78 pub quic: crate::quic::Client,
79
80 #[serde(default, skip_serializing_if = "Vec::is_empty")]
88 #[arg(id = "client-version", long = "client-version", env = "MOQ_CLIENT_VERSION")]
89 pub version: Vec<moq_net::Version>,
90
91 #[command(flatten)]
93 #[serde(default)]
94 pub tls: crate::tls::Client,
95
96 #[command(flatten)]
98 #[serde(default)]
99 pub backoff: Backoff,
100
101 #[cfg(feature = "websocket")]
104 #[command(flatten)]
105 #[serde(default)]
106 pub websocket: crate::websocket::Client,
107}
108
109impl ClientConfig {
110 pub fn init(self) -> crate::Result<Client> {
112 Client::new(self)
113 }
114
115 pub fn versions(&self) -> moq_net::Versions {
117 if self.version.is_empty() {
118 moq_net::Versions::all()
119 } else {
120 moq_net::Versions::from(self.version.clone())
121 }
122 }
123
124 #[cfg(any(feature = "quinn", feature = "noq", feature = "quiche", feature = "tcp"))]
127 pub(crate) fn effective_failover_delay(&self) -> std::time::Duration {
128 self.failover_delay.unwrap_or(crate::failover::DEFAULT_DELAY)
129 }
130
131 fn connect_timeout(&self) -> std::time::Duration {
132 self.timeout.unwrap_or(DEFAULT_CONNECT_TIMEOUT)
133 }
134}
135
136impl Default for ClientConfig {
137 fn default() -> Self {
138 Self {
139 connect: None,
140 bind: "[::]:0".parse().unwrap(),
141 backend: None,
142 failover_delay: None,
143 timeout: None,
144 quic: crate::quic::Client::default(),
145 version: Vec::new(),
146 tls: crate::tls::Client::default(),
147 backoff: Backoff::default(),
148 #[cfg(feature = "websocket")]
149 websocket: crate::websocket::Client::default(),
150 }
151 }
152}
153
154#[derive(Clone)]
158pub struct Client {
159 moq: moq_net::Client,
160 versions: moq_net::Versions,
165 connect: Option<Url>,
167 timeout: std::time::Duration,
169 backoff: Backoff,
170 #[cfg(feature = "tcp")]
173 failover_delay: std::time::Duration,
174 #[cfg(feature = "websocket")]
175 websocket: crate::websocket::Client,
176 tls: rustls::ClientConfig,
177 #[cfg(feature = "noq")]
178 noq: Option<crate::noq::NoqClient>,
179 #[cfg(feature = "quinn")]
180 quinn: Option<crate::quinn::QuinnClient>,
181 #[cfg(feature = "quiche")]
182 quiche: Option<crate::quiche::QuicheClient>,
183 #[cfg(feature = "iroh")]
184 iroh: Option<crate::iroh::Endpoint>,
185 #[cfg(feature = "iroh")]
186 iroh_addrs: Vec<std::net::SocketAddr>,
187}
188
189impl Client {
190 #[cfg(not(any(
194 feature = "noq",
195 feature = "quinn",
196 feature = "quiche",
197 feature = "websocket",
198 feature = "tcp",
199 feature = "uds"
200 )))]
201 pub fn new(_config: ClientConfig) -> crate::Result<Self> {
202 Err(Error::NoBackend(
203 "no QUIC or WebSocket backend compiled; enable noq, quinn, quiche, websocket, tcp, or uds feature",
204 ))
205 }
206
207 #[cfg(any(
209 feature = "noq",
210 feature = "quinn",
211 feature = "quiche",
212 feature = "websocket",
213 feature = "tcp",
214 feature = "uds"
215 ))]
216 pub fn new(config: ClientConfig) -> crate::Result<Self> {
217 #[cfg(any(feature = "noq", feature = "quinn", feature = "quiche"))]
218 let backend = config.backend.clone().unwrap_or_else(crate::default_quic_backend);
219
220 config.quic.validate()?;
221
222 let tls = config.tls.build()?;
223
224 #[cfg(feature = "noq")]
225 #[allow(unreachable_patterns)]
226 let noq = match backend {
227 QuicBackend::Noq => Some(crate::noq::NoqClient::new(&config)?),
228 _ => None,
229 };
230
231 #[cfg(feature = "quinn")]
232 #[allow(unreachable_patterns)]
233 let quinn = match backend {
234 QuicBackend::Quinn => Some(crate::quinn::QuinnClient::new(&config)?),
235 _ => None,
236 };
237
238 #[cfg(feature = "quiche")]
239 let quiche = match backend {
240 QuicBackend::Quiche => Some(crate::quiche::QuicheClient::new(&config)?),
241 _ => None,
242 };
243
244 let versions = config.versions();
245 #[cfg(feature = "tcp")]
247 let failover_delay = config.effective_failover_delay();
248 let timeout = config.connect_timeout();
249
250 Ok(Self {
251 moq: moq_net::Client::new().with_versions(versions.clone()),
252 versions,
253 connect: config.connect,
254 timeout,
255 backoff: config.backoff,
256 #[cfg(feature = "tcp")]
257 failover_delay,
258 #[cfg(feature = "websocket")]
259 websocket: config.websocket,
260 tls,
261 #[cfg(feature = "noq")]
262 noq,
263 #[cfg(feature = "quinn")]
264 quinn,
265 #[cfg(feature = "quiche")]
266 quiche,
267 #[cfg(feature = "iroh")]
268 iroh: None,
269 #[cfg(feature = "iroh")]
270 iroh_addrs: Vec::new(),
271 })
272 }
273
274 #[cfg(feature = "iroh")]
279 pub fn with_iroh(mut self, iroh: crate::iroh::Endpoint) -> Self {
280 self.iroh = Some(iroh);
281 self
282 }
283
284 #[cfg(feature = "iroh")]
289 pub fn with_iroh_addrs(mut self, addrs: Vec<std::net::SocketAddr>) -> Self {
290 self.iroh_addrs = addrs;
291 self
292 }
293
294 pub fn with_publisher(mut self, publish: impl moq_net::Consume<moq_net::origin::Consumer>) -> Self {
296 self.moq = self.moq.with_publisher(publish);
297 self
298 }
299
300 pub fn with_subscriber(mut self, subscribe: moq_net::origin::Producer) -> Self {
302 self.moq = self.moq.with_subscriber(subscribe);
303 self
304 }
305
306 pub fn with_stats(mut self, stats: moq_net::stats::Session) -> Self {
309 self.moq = self.moq.with_stats(stats);
310 self
311 }
312
313 pub fn with_cost(mut self, cost: u64) -> Self {
315 self.moq = self.moq.with_cost(cost);
316 self
317 }
318
319 pub fn with_peer_origin(mut self, origin: moq_net::Origin) -> Self {
322 self.moq = self.moq.with_peer_origin(origin);
323 self
324 }
325
326 pub fn reconnect(&self, url: Url) -> Reconnect {
331 Reconnect::new(self.clone(), url, self.backoff.clone())
332 }
333
334 pub fn publish(self, origin: moq_net::origin::Consumer) -> Option<Reconnect> {
340 let url = self.connect.clone()?;
341 Some(self.with_publisher(origin).reconnect(url))
342 }
343
344 pub fn consume(self, origin: moq_net::origin::Producer) -> Option<Reconnect> {
356 let url = self.connect.clone()?;
357 let origin = origin.with_linger(self.backoff.linger());
358 Some(self.with_subscriber(origin).reconnect(url))
359 }
360
361 #[cfg(not(any(
365 feature = "noq",
366 feature = "quinn",
367 feature = "quiche",
368 feature = "iroh",
369 feature = "websocket",
370 feature = "tcp",
371 feature = "uds"
372 )))]
373 pub async fn connect(&self, _url: Url) -> crate::Result<moq_net::Session> {
374 Err(Error::NoBackend(
375 "no backend compiled; enable noq, quinn, quiche, iroh, websocket, tcp, or uds feature",
376 ))
377 }
378
379 #[cfg(any(
386 feature = "noq",
387 feature = "quinn",
388 feature = "quiche",
389 feature = "iroh",
390 feature = "websocket",
391 feature = "tcp",
392 feature = "uds"
393 ))]
394 pub async fn connect(&self, url: Url) -> crate::Result<moq_net::Session> {
395 let attempt = Box::pin(self.connect_inner(url));
398
399 let pair = match self.timeout.is_zero() {
403 true => attempt.await?,
404 false => match tokio::time::timeout(self.timeout, attempt).await {
405 Ok(res) => res?,
406 Err(_) => return Err(Error::ConnectTimeout(self.timeout)),
407 },
408 };
409
410 tracing::info!(version = %pair.0.version(), "connected");
411 Ok(crate::spawn_session(pair))
412 }
413
414 #[cfg(any(
416 feature = "noq",
417 feature = "quinn",
418 feature = "quiche",
419 feature = "iroh",
420 feature = "websocket",
421 feature = "tcp",
422 feature = "uds"
423 ))]
424 fn moq_with_path(&self, path: Option<String>) -> moq_net::Client {
425 match path {
426 Some(path) => self.moq.clone().with_path(path),
427 None => self.moq.clone(),
428 }
429 }
430
431 #[cfg(any(
432 feature = "noq",
433 feature = "quinn",
434 feature = "quiche",
435 feature = "iroh",
436 feature = "websocket",
437 feature = "tcp",
438 feature = "uds"
439 ))]
440 async fn connect_inner(&self, url: Url) -> crate::Result<(moq_net::Session, moq_net::Driver)> {
441 let moq = self.moq_with_path(setup_path(&url));
445
446 #[cfg(feature = "tcp")]
449 if url.scheme() == "tcp" {
450 let session = crate::tcp::connect(url, &self.versions.alpns(), self.failover_delay).await?;
451 return Ok(moq.connect(session).await?);
452 }
453
454 #[cfg(all(feature = "uds", unix))]
457 if url.scheme() == "unix" {
458 let session = crate::unix::connect(url, &self.versions.alpns()).await?;
459 return Ok(moq.connect(session).await?);
460 }
461
462 #[cfg(feature = "iroh")]
467 if url.scheme() == "iroh" {
468 let endpoint = self.iroh.as_ref().ok_or(Error::IrohDisabled)?;
469 let target = request_target(&url);
470 let (session, binding) = crate::iroh::connect(endpoint, url, self.iroh_addrs.iter().copied()).await?;
471
472 let moq = match binding {
473 crate::iroh::Binding::Raw => self.moq_with_path(target),
474 crate::iroh::Binding::H3 => self.moq.clone(),
475 };
476
477 return Ok(moq.connect(session).await?);
478 }
479
480 #[cfg(feature = "noq")]
481 if let Some(noq) = self.noq.as_ref() {
482 let tls = self.tls.clone();
483 let quic_url = url.clone();
484 let quic_handle = async { noq.connect(&tls, quic_url, &self.versions).await.map_err(Error::from) };
485
486 #[cfg(feature = "websocket")]
487 {
488 return self.race_moq_connect(&moq, url, quic_handle).await;
489 }
490
491 #[cfg(not(feature = "websocket"))]
492 {
493 let session = quic_handle.await?;
494 return Ok(moq.connect(session).await?);
495 }
496 }
497
498 #[cfg(feature = "quinn")]
499 if let Some(quinn) = self.quinn.as_ref() {
500 let tls = self.tls.clone();
501 let quic_url = url.clone();
502 let quic_handle = async { quinn.connect(&tls, quic_url, &self.versions).await.map_err(Error::from) };
503
504 #[cfg(feature = "websocket")]
505 {
506 return self.race_moq_connect(&moq, url, quic_handle).await;
507 }
508
509 #[cfg(not(feature = "websocket"))]
510 {
511 let session = quic_handle.await?;
512 return Ok(moq.connect(session).await?);
513 }
514 }
515
516 #[cfg(feature = "quiche")]
517 if let Some(quiche) = self.quiche.as_ref() {
518 let quic_url = url.clone();
519 let quic_handle = async { quiche.connect(quic_url, &self.versions).await.map_err(Error::from) };
520
521 #[cfg(feature = "websocket")]
522 {
523 return self.race_moq_connect(&moq, url, quic_handle).await;
524 }
525
526 #[cfg(not(feature = "websocket"))]
527 {
528 let session = quic_handle.await?;
529 return Ok(moq.connect(session).await?);
530 }
531 }
532
533 #[cfg(feature = "websocket")]
534 {
535 let alpns = self.versions.alpns();
536 let session = crate::websocket::connect(&self.websocket, &self.tls, url, &alpns).await?;
537 return Ok(moq.connect(session).await?);
538 }
539
540 #[cfg(not(feature = "websocket"))]
541 return Err(Error::NoBackend("no QUIC backend matched; this should not happen"));
542 }
543
544 #[cfg(feature = "websocket")]
550 async fn race_moq_connect<Q, S>(
551 &self,
552 moq: &moq_net::Client,
553 url: Url,
554 quic: Q,
555 ) -> crate::Result<(moq_net::Session, moq_net::Driver)>
556 where
557 Q: Future<Output = crate::Result<S>>,
558 S: web_transport_trait::Session,
559 {
560 let alpns = self.versions.alpns();
561 let ws_config = self.websocket.clone();
562 let ws_tls = self.tls.clone();
563 let websocket = async move {
564 crate::websocket::race_handle(&ws_config, &ws_tls, url, &alpns)
565 .await
566 .map(|res| res.map_err(Error::from))
567 };
568
569 match race_transport_connect(quic, websocket).await? {
570 TransportRace::Quic(quic) => Ok(moq.connect(quic).await?),
571 TransportRace::WebSocket(websocket) => Ok(self.moq.connect(websocket).await?),
572 }
573 }
574}
575
576#[cfg(any(
583 feature = "noq",
584 feature = "quinn",
585 feature = "quiche",
586 feature = "iroh",
587 feature = "websocket",
588 feature = "tcp",
589 feature = "uds"
590))]
591fn request_target(url: &Url) -> Option<String> {
592 let target = match url.query().filter(|query| !query.is_empty()) {
596 Some(query) => format!("{}?{}", url.path(), query),
597 None => url.path().to_owned(),
598 };
599
600 (!target.is_empty()).then_some(target)
601}
602
603#[cfg(any(
611 feature = "noq",
612 feature = "quinn",
613 feature = "quiche",
614 feature = "iroh",
615 feature = "websocket",
616 feature = "tcp",
617 feature = "uds"
618))]
619fn setup_path(url: &Url) -> Option<String> {
620 match url.scheme() {
621 "unix" => url
625 .query_pairs()
626 .find(|(k, _)| k == "path")
627 .map(|(_, v)| v.into_owned())
628 .filter(|path| !path.is_empty()),
629 "moqt" | "moql" | "tcp" => request_target(url),
632 _ => None,
633 }
634}
635
636#[cfg(feature = "websocket")]
637#[derive(Debug, PartialEq, Eq)]
638enum TransportRace<Q, W> {
639 Quic(Q),
640 WebSocket(W),
641}
642
643#[cfg(feature = "websocket")]
644async fn race_transport_connect<Q, W, QT, WT>(quic: Q, websocket: W) -> crate::Result<TransportRace<QT, WT>>
645where
646 Q: Future<Output = crate::Result<QT>>,
647 W: Future<Output = Option<crate::Result<WT>>>,
648{
649 tokio::pin!(quic);
650 tokio::pin!(websocket);
651
652 let mut quic_err = None;
653 let mut websocket_err = None;
654 let mut quic_done = false;
655 let mut websocket_done = false;
656
657 loop {
658 tokio::select! {
659 res = &mut quic, if !quic_done => {
660 match res {
661 Ok(session) => return Ok(TransportRace::Quic(session)),
662 Err(err) if err.is_auth() => return Err(err),
663 Err(err) => {
664 tracing::warn!(%err, "QUIC connection failed");
665 quic_err = Some(err);
666 quic_done = true;
667 }
668 }
669 }
670 res = &mut websocket, if !websocket_done => {
671 match res {
672 Some(Ok(session)) => return Ok(TransportRace::WebSocket(session)),
673 Some(Err(err)) if err.is_auth() => return Err(err),
674 Some(Err(err)) => {
675 tracing::warn!(%err, "WebSocket connection failed");
676 websocket_err = Some(err);
677 websocket_done = true;
678 }
679 None => {
680 websocket_done = true;
681 }
682 }
683 }
684 else => break,
685 }
686
687 if quic_done && websocket_done {
688 break;
689 }
690 }
691
692 match (quic_err, websocket_err) {
693 (Some(quic), Some(websocket)) => Err(Error::TransportRace {
694 quic: std::sync::Arc::new(quic),
695 websocket: std::sync::Arc::new(websocket),
696 }),
697 (Some(err), None) | (None, Some(err)) => Err(err),
698 (None, None) => Err(Error::ConnectFailed),
699 }
700}
701
702#[cfg(test)]
703mod tests {
704 use super::*;
705 use clap::Parser;
706
707 #[cfg(any(
708 feature = "noq",
709 feature = "quinn",
710 feature = "quiche",
711 feature = "iroh",
712 feature = "websocket",
713 feature = "tcp",
714 feature = "uds"
715 ))]
716 #[test]
717 fn setup_path_covers_the_uri_less_transports() {
718 let cases = [
721 ("unix:///run/moq.sock?path=/room", Some("/room")),
722 ("unix:///run/moq.sock?path=/room%3Fjwt%3Dabc", Some("/room?jwt=abc")),
725 ("unix:///run/moq.sock?path=", None),
726 ("unix:///run/moq.sock", None),
727 ("tcp://localhost:4443/room", Some("/room")),
728 ("tcp://localhost:4443/room?jwt=abc", Some("/room?jwt=abc")),
729 ("tcp://localhost:4443", None),
730 ("moqt://relay.example.com/anon", Some("/anon")),
733 ("moqt://relay.example.com/anon?jwt=abc", Some("/anon?jwt=abc")),
734 ("moql://relay.example.com/anon?jwt=abc", Some("/anon?jwt=abc")),
735 ("moqt://relay.example.com", None),
736 ("moqt://relay.example.com/anon?jwt=abc#pos:12", Some("/anon?jwt=abc")),
738 ("moqt://relay.example.com/anon#pos:12", Some("/anon")),
739 ("moqt://relay.example.com/anon?", Some("/anon")),
741 ("moqt://relay.example.com?", None),
742 ("https://relay.example.com/anon?jwt=abc", None),
745 ("http://relay.example.com/anon", None),
746 ("wss://relay.example.com/anon?jwt=abc", None),
747 ("iroh://k5lnrlndqpqcgh4d5nhbnbnhcyrgvw6ttxwrsvsu4nlt6foorxaa/anon", None),
749 ];
750
751 for (url, want) in cases {
752 let url = Url::parse(url).unwrap();
753 let got = setup_path(&url);
754 assert_eq!(got.as_deref(), want, "{url}");
755 }
756 }
757
758 #[cfg(any(
761 feature = "noq",
762 feature = "quinn",
763 feature = "quiche",
764 feature = "iroh",
765 feature = "websocket",
766 feature = "tcp",
767 feature = "uds"
768 ))]
769 #[test]
770 fn request_target_joins_the_path_and_query() {
771 const PEER: &str = "k5lnrlndqpqcgh4d5nhbnbnhcyrgvw6ttxwrsvsu4nlt6foorxaa";
772
773 let cases = [
774 (format!("iroh://{PEER}/room?jwt=abc"), Some("/room?jwt=abc")),
775 (format!("iroh://{PEER}/room"), Some("/room")),
776 (format!("iroh://{PEER}"), None),
777 (format!("iroh://{PEER}/"), Some("/")),
778 ];
779
780 for (url, want) in cases {
781 let url = Url::parse(&url).unwrap();
782 let got = request_target(&url);
783 assert_eq!(got.as_deref(), want, "{url}");
784 }
785 }
786
787 #[test]
788 fn test_toml_disable_verify_survives_update_from() {
789 let toml = r#"
790 tls.disable_verify = true
791 "#;
792
793 let mut config: ClientConfig = toml::from_str(toml).unwrap();
794 assert_eq!(config.tls.disable_verify, Some(true));
795
796 config.update_from(["test"]);
798 assert_eq!(config.tls.disable_verify, Some(true));
799 }
800
801 #[test]
802 fn test_cli_disable_verify_flag() {
803 let config = ClientConfig::parse_from(["test", "--client-tls-disable-verify"]);
804 assert_eq!(config.tls.disable_verify, Some(true));
805 }
806
807 #[test]
808 fn test_cli_disable_verify_explicit_false() {
809 let config = ClientConfig::parse_from(["test", "--client-tls-disable-verify=false"]);
810 assert_eq!(config.tls.disable_verify, Some(false));
811 }
812
813 #[test]
814 fn test_cli_disable_verify_explicit_true() {
815 let config = ClientConfig::parse_from(["test", "--client-tls-disable-verify=true"]);
816 assert_eq!(config.tls.disable_verify, Some(true));
817 }
818
819 #[test]
820 fn test_cli_deprecated_tls_flags_fold_into_canonical() {
821 let config = ClientConfig::parse_from(["test", "--tls-disable-verify=true", "--tls-fingerprint", "abcd1234"]);
825 assert_eq!(
826 config.tls.disable_verify, None,
827 "deprecated flag must not set the canonical field"
828 );
829 assert_eq!(config.tls.effective_disable_verify(), Some(true));
830 assert_eq!(config.tls.effective_fingerprint(), vec!["abcd1234"]);
831 }
832
833 #[test]
834 fn test_canonical_tls_flag_wins_over_deprecated() {
835 let config = ClientConfig::parse_from([
837 "test",
838 "--client-tls-disable-verify=false",
839 "--tls-disable-verify=true",
840 "--client-tls-fingerprint",
841 "aaaa",
842 "--tls-fingerprint",
843 "bbbb",
844 ]);
845 assert_eq!(config.tls.effective_disable_verify(), Some(false));
846 assert_eq!(config.tls.effective_fingerprint(), vec!["aaaa", "bbbb"]);
847 }
848
849 #[test]
850 fn test_cli_no_disable_verify() {
851 let config = ClientConfig::parse_from(["test"]);
852 assert_eq!(config.tls.disable_verify, None);
853 }
854
855 #[test]
856 fn test_toml_failover_delay_survives_update_from() {
857 let toml = r#"
858 failover_delay = "1s"
859 "#;
860
861 let mut config: ClientConfig = toml::from_str(toml).unwrap();
862 assert_eq!(config.failover_delay, Some(std::time::Duration::from_secs(1)));
863
864 config.update_from(["test"]);
866 assert_eq!(config.failover_delay, Some(std::time::Duration::from_secs(1)));
867 }
868
869 #[test]
870 fn test_cli_failover_delay() {
871 let config = ClientConfig::parse_from(["test", "--client-failover-delay", "50ms"]);
872 assert_eq!(config.failover_delay, Some(std::time::Duration::from_millis(50)));
873 }
874
875 #[test]
876 fn test_toml_fingerprint_survives_update_from() {
877 let toml = r#"
878 tls.fingerprint = ["abcd1234", "ef567890"]
879 "#;
880
881 let mut config: ClientConfig = toml::from_str(toml).unwrap();
882 assert_eq!(config.tls.fingerprint, vec!["abcd1234", "ef567890"]);
883
884 config.update_from(["test"]);
886 assert_eq!(config.tls.fingerprint, vec!["abcd1234", "ef567890"]);
887 }
888
889 #[test]
890 fn test_toml_fingerprint_accepts_single_string() {
891 let toml = r#"
892 tls.fingerprint = "abcd1234"
893 "#;
894
895 let config: ClientConfig = toml::from_str(toml).unwrap();
896 assert_eq!(config.tls.fingerprint, vec!["abcd1234"]);
897 }
898
899 #[test]
900 fn test_cli_fingerprint() {
901 let config = ClientConfig::parse_from(["test", "--client-tls-fingerprint", "abcd1234"]);
902 assert_eq!(config.tls.fingerprint, vec!["abcd1234"]);
903 }
904
905 #[test]
906 fn test_toml_version_survives_update_from() {
907 let toml = r#"
908 version = ["moq-lite-02"]
909 "#;
910
911 let mut config: ClientConfig = toml::from_str(toml).unwrap();
912 assert_eq!(config.version, vec!["moq-lite-02".parse::<moq_net::Version>().unwrap()]);
913
914 config.update_from(["test"]);
916 assert_eq!(config.version, vec!["moq-lite-02".parse::<moq_net::Version>().unwrap()]);
917 }
918
919 #[test]
920 fn test_cli_version() {
921 let config = ClientConfig::parse_from(["test", "--client-version", "moq-lite-03"]);
922 assert_eq!(config.version, vec!["moq-lite-03".parse::<moq_net::Version>().unwrap()]);
923 }
924
925 #[test]
926 fn test_toml_connect_survives_update_from() {
927 let toml = r#"
928 connect = "https://relay.example.com/anon"
929 "#;
930
931 let mut config: ClientConfig = toml::from_str(toml).unwrap();
932 assert_eq!(
933 config.connect.as_ref().unwrap().as_str(),
934 "https://relay.example.com/anon"
935 );
936
937 config.update_from(["test"]);
939 assert_eq!(
940 config.connect.as_ref().unwrap().as_str(),
941 "https://relay.example.com/anon"
942 );
943 }
944
945 #[test]
946 fn test_cli_connect() {
947 let config = ClientConfig::parse_from(["test", "--client-connect", "https://relay.example.com/anon"]);
948 assert_eq!(
949 config.connect.as_ref().unwrap().as_str(),
950 "https://relay.example.com/anon"
951 );
952 }
953
954 #[test]
955 fn test_toml_host_name_survives_update_from() {
956 let toml = r#"
957 tls.host_name = "example.host"
958 "#;
959
960 let mut config: ClientConfig = toml::from_str(toml).unwrap();
961 assert_eq!(config.tls.host_name.as_deref(), Some("example.host"));
962
963 config.update_from(["test"]);
965 assert_eq!(config.tls.host_name.as_deref(), Some("example.host"));
966 }
967
968 #[test]
969 fn test_cli_host_name() {
970 let config = ClientConfig::parse_from(["test", "--client-tls-host-name", "override.example"]);
971 assert_eq!(config.tls.host_name.as_deref(), Some("override.example"));
972 }
973
974 #[test]
975 fn test_cli_no_version_defaults_to_all() {
976 let config = ClientConfig::parse_from(["test"]);
977 assert!(config.version.is_empty());
978 assert_eq!(config.versions().alpns().len(), moq_net::ALPNS.len());
980 }
981
982 #[cfg(feature = "websocket")]
983 #[tokio::test]
984 async fn race_transport_connect_stops_on_quic_auth_error() {
985 let quic = async { Err::<usize, _>(crate::ConnectError::Unauthorized.into()) };
986 let websocket = async {
987 tokio::task::yield_now().await;
989 Some(Ok(1usize))
990 };
991
992 let err = super::race_transport_connect(quic, websocket).await.unwrap_err();
993 assert_eq!(err.connect_error(), Some(crate::ConnectError::Unauthorized));
994 }
995
996 #[cfg(feature = "websocket")]
997 #[tokio::test]
998 async fn race_transport_connect_keeps_websocket_after_quic_non_auth_error() {
999 let quic = async { Err::<usize, _>(Error::ConnectFailed) };
1000 let websocket = async { Some(Ok(7usize)) };
1001
1002 let value = super::race_transport_connect(quic, websocket).await.unwrap();
1003 assert_eq!(value, super::TransportRace::WebSocket(7));
1004 }
1005
1006 #[cfg(feature = "websocket")]
1007 #[tokio::test]
1008 async fn race_transport_connect_returns_when_quic_transport_connects() {
1009 let quic = async { Ok("quic") };
1010 let websocket = std::future::pending::<Option<crate::Result<&str>>>();
1011
1012 let value = tokio::time::timeout(
1013 std::time::Duration::from_secs(1),
1014 super::race_transport_connect(quic, websocket),
1015 )
1016 .await
1017 .expect("race waited for WebSocket after QUIC transport connected")
1018 .unwrap();
1019 assert_eq!(value, super::TransportRace::Quic("quic"));
1020 }
1021
1022 #[test]
1023 fn connect_timeout_defaults_to_thirty_seconds() {
1024 let config = ClientConfig::parse_from(["test"]);
1025 assert_eq!(config.timeout, None);
1026 assert_eq!(config.connect_timeout(), DEFAULT_CONNECT_TIMEOUT);
1027 }
1028
1029 #[cfg(feature = "websocket")]
1037 #[tokio::test]
1038 async fn connect_times_out_against_a_peer_that_never_speaks() {
1039 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1040 let addr = listener.local_addr().unwrap();
1041
1042 let timeout = DEFAULT_CONNECT_TIMEOUT;
1043 let mut config = ClientConfig {
1044 timeout: Some(timeout),
1045 ..Default::default()
1046 };
1047 config.websocket.delay = Some(std::time::Duration::ZERO);
1048 let client = config.init().unwrap();
1049
1050 let url: Url = format!("https://127.0.0.1:{}/", addr.port()).parse().unwrap();
1053
1054 let mut attempt = Box::pin(client.connect(url));
1055 let _silent = tokio::select! {
1056 res = &mut attempt => match res {
1057 Err(err) => panic!("connect failed before the silent peer accepted it: {err}"),
1058 Ok(_) => panic!("connected to a peer that never spoke"),
1059 },
1060 res = listener.accept() => res.unwrap().0,
1061 };
1062
1063 tokio::time::pause();
1066 tokio::time::advance(timeout).await;
1067
1068 let err = match attempt.await {
1069 Err(err) => err,
1070 Ok(_) => panic!("connected to a peer that never spoke"),
1071 };
1072
1073 assert!(matches!(err, Error::ConnectTimeout(_)), "unexpected error: {err}");
1074 }
1075}