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 pub fn resolved_failover_delay(&self) -> std::time::Duration {
131 self.failover_delay.unwrap_or(crate::failover::DEFAULT_DELAY)
132 }
133
134 pub fn resolved_connect_timeout(&self) -> std::time::Duration {
137 self.timeout.unwrap_or(DEFAULT_CONNECT_TIMEOUT)
138 }
139}
140
141impl Default for ClientConfig {
142 fn default() -> Self {
143 Self {
144 connect: None,
145 bind: "[::]:0".parse().unwrap(),
146 backend: None,
147 failover_delay: None,
148 timeout: None,
149 quic: crate::quic::Client::default(),
150 version: Vec::new(),
151 tls: crate::tls::Client::default(),
152 backoff: Backoff::default(),
153 #[cfg(feature = "websocket")]
154 websocket: crate::websocket::Client::default(),
155 }
156 }
157}
158
159#[derive(Clone)]
163pub struct Client {
164 moq: moq_net::Client,
165 versions: moq_net::Versions,
170 connect: Option<Url>,
172 timeout: std::time::Duration,
174 backoff: Backoff,
175 #[cfg(feature = "tcp")]
178 failover_delay: std::time::Duration,
179 #[cfg(feature = "websocket")]
180 websocket: crate::websocket::Client,
181 tls: rustls::ClientConfig,
182 #[cfg(feature = "noq")]
183 noq: Option<crate::noq::NoqClient>,
184 #[cfg(feature = "quinn")]
185 quinn: Option<crate::quinn::QuinnClient>,
186 #[cfg(feature = "quiche")]
187 quiche: Option<crate::quiche::QuicheClient>,
188 #[cfg(feature = "iroh")]
189 iroh: Option<crate::iroh::Endpoint>,
190 #[cfg(feature = "iroh")]
191 iroh_addrs: Vec<std::net::SocketAddr>,
192}
193
194impl Client {
195 #[cfg(not(any(
199 feature = "noq",
200 feature = "quinn",
201 feature = "quiche",
202 feature = "websocket",
203 feature = "tcp",
204 feature = "uds"
205 )))]
206 pub fn new(_config: ClientConfig) -> crate::Result<Self> {
207 Err(Error::NoBackend(
208 "no QUIC or WebSocket backend compiled; enable noq, quinn, quiche, websocket, tcp, or uds feature",
209 ))
210 }
211
212 #[cfg(any(
214 feature = "noq",
215 feature = "quinn",
216 feature = "quiche",
217 feature = "websocket",
218 feature = "tcp",
219 feature = "uds"
220 ))]
221 pub fn new(config: ClientConfig) -> crate::Result<Self> {
222 #[cfg(any(feature = "noq", feature = "quinn", feature = "quiche"))]
223 let backend = config.backend.clone().unwrap_or_else(crate::default_quic_backend);
224
225 config.quic.validate()?;
226 config.backoff.validate()?;
227
228 let tls = config.tls.build()?;
229
230 #[cfg(feature = "noq")]
231 #[allow(unreachable_patterns)]
232 let noq = match backend {
233 QuicBackend::Noq => Some(crate::noq::NoqClient::new(&config)?),
234 _ => None,
235 };
236
237 #[cfg(feature = "quinn")]
238 #[allow(unreachable_patterns)]
239 let quinn = match backend {
240 QuicBackend::Quinn => Some(crate::quinn::QuinnClient::new(&config)?),
241 _ => None,
242 };
243
244 #[cfg(feature = "quiche")]
245 let quiche = match backend {
246 QuicBackend::Quiche => Some(crate::quiche::QuicheClient::new(&config)?),
247 _ => None,
248 };
249
250 let versions = config.versions();
251 #[cfg(feature = "tcp")]
253 let failover_delay = config.resolved_failover_delay();
254 let timeout = config.resolved_connect_timeout();
255
256 Ok(Self {
257 moq: moq_net::Client::new().with_versions(versions.clone()),
258 versions,
259 connect: config.connect,
260 timeout,
261 backoff: config.backoff,
262 #[cfg(feature = "tcp")]
263 failover_delay,
264 #[cfg(feature = "websocket")]
265 websocket: config.websocket,
266 tls,
267 #[cfg(feature = "noq")]
268 noq,
269 #[cfg(feature = "quinn")]
270 quinn,
271 #[cfg(feature = "quiche")]
272 quiche,
273 #[cfg(feature = "iroh")]
274 iroh: None,
275 #[cfg(feature = "iroh")]
276 iroh_addrs: Vec::new(),
277 })
278 }
279
280 #[cfg(feature = "iroh")]
285 pub fn with_iroh(mut self, iroh: crate::iroh::Endpoint) -> Self {
286 self.iroh = Some(iroh);
287 self
288 }
289
290 #[cfg(feature = "iroh")]
295 pub fn with_iroh_addrs(mut self, addrs: Vec<std::net::SocketAddr>) -> Self {
296 self.iroh_addrs = addrs;
297 self
298 }
299
300 pub fn with_publisher(mut self, publish: impl moq_net::Consume<moq_net::origin::Consumer>) -> Self {
302 self.moq = self.moq.with_publisher(publish);
303 self
304 }
305
306 pub fn with_subscriber(mut self, subscribe: moq_net::origin::Producer) -> Self {
308 self.moq = self.moq.with_subscriber(subscribe);
309 self
310 }
311
312 pub fn with_stats(mut self, stats: moq_net::stats::Session) -> Self {
315 self.moq = self.moq.with_stats(stats);
316 self
317 }
318
319 pub fn with_cost(mut self, cost: u64) -> Self {
321 self.moq = self.moq.with_cost(cost);
322 self
323 }
324
325 pub fn with_peer_origin(mut self, origin: moq_net::Origin) -> Self {
328 self.moq = self.moq.with_peer_origin(origin);
329 self
330 }
331
332 pub fn reconnect(&self, url: Url) -> Reconnect {
337 Reconnect::new(self.clone(), url, self.backoff.clone())
338 }
339
340 pub fn publish(self, origin: moq_net::origin::Consumer) -> Option<Reconnect> {
346 let url = self.connect.clone()?;
347 Some(self.with_publisher(origin).reconnect(url))
348 }
349
350 pub fn consume(self, origin: moq_net::origin::Producer) -> Option<Reconnect> {
362 let url = self.connect.clone()?;
363 let origin = origin.with_linger(self.backoff.linger());
364 Some(self.with_subscriber(origin).reconnect(url))
365 }
366
367 #[cfg(not(any(
371 feature = "noq",
372 feature = "quinn",
373 feature = "quiche",
374 feature = "iroh",
375 feature = "websocket",
376 feature = "tcp",
377 feature = "uds"
378 )))]
379 pub async fn connect(&self, _url: Url) -> crate::Result<moq_net::Session> {
380 Err(Error::NoBackend(
381 "no backend compiled; enable noq, quinn, quiche, iroh, websocket, tcp, or uds feature",
382 ))
383 }
384
385 #[cfg(any(
392 feature = "noq",
393 feature = "quinn",
394 feature = "quiche",
395 feature = "iroh",
396 feature = "websocket",
397 feature = "tcp",
398 feature = "uds"
399 ))]
400 pub async fn connect(&self, url: Url) -> crate::Result<moq_net::Session> {
401 let attempt = Box::pin(self.connect_inner(url));
404
405 let pair = match self.timeout.is_zero() {
409 true => attempt.await?,
410 false => match tokio::time::timeout(self.timeout, attempt).await {
411 Ok(res) => res?,
412 Err(_) => return Err(Error::ConnectTimeout(self.timeout)),
413 },
414 };
415
416 tracing::info!(version = %pair.0.version(), "connected");
417 Ok(crate::spawn_session(pair))
418 }
419
420 #[cfg(any(
422 feature = "noq",
423 feature = "quinn",
424 feature = "quiche",
425 feature = "iroh",
426 feature = "websocket",
427 feature = "tcp",
428 feature = "uds"
429 ))]
430 fn moq_with_path(&self, path: Option<String>) -> moq_net::Client {
431 match path {
432 Some(path) => self.moq.clone().with_path(path),
433 None => self.moq.clone(),
434 }
435 }
436
437 #[cfg(any(
438 feature = "noq",
439 feature = "quinn",
440 feature = "quiche",
441 feature = "iroh",
442 feature = "websocket",
443 feature = "tcp",
444 feature = "uds"
445 ))]
446 async fn connect_inner(&self, url: Url) -> crate::Result<(moq_net::Session, moq_net::Driver)> {
447 let moq = self.moq_with_path(setup_path(&url));
451
452 #[cfg(feature = "tcp")]
455 if url.scheme() == "tcp" {
456 let session = crate::tcp::connect(url, &self.versions.alpns(), self.failover_delay).await?;
457 return Ok(moq.connect(session).await?);
458 }
459
460 #[cfg(all(feature = "uds", unix))]
463 if url.scheme() == "unix" {
464 let session = crate::unix::connect(url, &self.versions.alpns()).await?;
465 return Ok(moq.connect(session).await?);
466 }
467
468 #[cfg(feature = "iroh")]
473 if url.scheme() == "iroh" {
474 let endpoint = self.iroh.as_ref().ok_or(Error::IrohDisabled)?;
475 let target = request_target(&url);
476 let (session, binding) = crate::iroh::connect(endpoint, url, self.iroh_addrs.iter().copied()).await?;
477
478 let moq = match binding {
479 crate::iroh::Binding::Raw => self.moq_with_path(target),
480 crate::iroh::Binding::H3 => self.moq.clone(),
481 };
482
483 return Ok(moq.connect(session).await?);
484 }
485
486 #[cfg(feature = "noq")]
487 if let Some(noq) = self.noq.as_ref() {
488 let tls = self.tls.clone();
489 let quic_url = url.clone();
490 let quic_handle = async { noq.connect(&tls, quic_url, &self.versions).await.map_err(Error::from) };
491
492 #[cfg(feature = "websocket")]
493 {
494 return self.race_moq_connect(&moq, url, quic_handle).await;
495 }
496
497 #[cfg(not(feature = "websocket"))]
498 {
499 let session = quic_handle.await?;
500 return Ok(moq.connect(session).await?);
501 }
502 }
503
504 #[cfg(feature = "quinn")]
505 if let Some(quinn) = self.quinn.as_ref() {
506 let tls = self.tls.clone();
507 let quic_url = url.clone();
508 let quic_handle = async { quinn.connect(&tls, quic_url, &self.versions).await.map_err(Error::from) };
509
510 #[cfg(feature = "websocket")]
511 {
512 return self.race_moq_connect(&moq, url, quic_handle).await;
513 }
514
515 #[cfg(not(feature = "websocket"))]
516 {
517 let session = quic_handle.await?;
518 return Ok(moq.connect(session).await?);
519 }
520 }
521
522 #[cfg(feature = "quiche")]
523 if let Some(quiche) = self.quiche.as_ref() {
524 let quic_url = url.clone();
525 let quic_handle = async { quiche.connect(quic_url, &self.versions).await.map_err(Error::from) };
526
527 #[cfg(feature = "websocket")]
528 {
529 return self.race_moq_connect(&moq, url, quic_handle).await;
530 }
531
532 #[cfg(not(feature = "websocket"))]
533 {
534 let session = quic_handle.await?;
535 return Ok(moq.connect(session).await?);
536 }
537 }
538
539 #[cfg(feature = "websocket")]
540 {
541 let alpns = self.versions.alpns();
542 let session = crate::websocket::connect(&self.websocket, &self.tls, url, &alpns).await?;
543 return Ok(moq.connect(session).await?);
544 }
545
546 #[cfg(not(feature = "websocket"))]
547 return Err(Error::NoBackend("no QUIC backend matched; this should not happen"));
548 }
549
550 #[cfg(feature = "websocket")]
556 async fn race_moq_connect<Q, S>(
557 &self,
558 moq: &moq_net::Client,
559 url: Url,
560 quic: Q,
561 ) -> crate::Result<(moq_net::Session, moq_net::Driver)>
562 where
563 Q: Future<Output = crate::Result<S>>,
564 S: web_transport_trait::Session,
565 {
566 let alpns = self.versions.alpns();
567 let ws_config = self.websocket.clone();
568 let ws_tls = self.tls.clone();
569 let websocket = async move {
570 crate::websocket::race_handle(&ws_config, &ws_tls, url, &alpns)
571 .await
572 .map(|res| res.map_err(Error::from))
573 };
574
575 match race_transport_connect(quic, websocket).await? {
576 TransportRace::Quic(quic) => Ok(moq.connect(quic).await?),
577 TransportRace::WebSocket(websocket) => Ok(self.moq.connect(websocket).await?),
578 }
579 }
580}
581
582#[cfg(any(
589 feature = "noq",
590 feature = "quinn",
591 feature = "quiche",
592 feature = "iroh",
593 feature = "websocket",
594 feature = "tcp",
595 feature = "uds"
596))]
597fn request_target(url: &Url) -> Option<String> {
598 let target = match url.query().filter(|query| !query.is_empty()) {
602 Some(query) => format!("{}?{}", url.path(), query),
603 None => url.path().to_owned(),
604 };
605
606 (!target.is_empty()).then_some(target)
607}
608
609#[cfg(any(
617 feature = "noq",
618 feature = "quinn",
619 feature = "quiche",
620 feature = "iroh",
621 feature = "websocket",
622 feature = "tcp",
623 feature = "uds"
624))]
625fn setup_path(url: &Url) -> Option<String> {
626 match url.scheme() {
627 "unix" => url
631 .query_pairs()
632 .find(|(k, _)| k == "path")
633 .map(|(_, v)| v.into_owned())
634 .filter(|path| !path.is_empty()),
635 "moqt" | "moql" | "tcp" => request_target(url),
638 _ => None,
639 }
640}
641
642#[cfg(feature = "websocket")]
643#[derive(Debug, PartialEq, Eq)]
644enum TransportRace<Q, W> {
645 Quic(Q),
646 WebSocket(W),
647}
648
649#[cfg(feature = "websocket")]
650async fn race_transport_connect<Q, W, QT, WT>(quic: Q, websocket: W) -> crate::Result<TransportRace<QT, WT>>
651where
652 Q: Future<Output = crate::Result<QT>>,
653 W: Future<Output = Option<crate::Result<WT>>>,
654{
655 tokio::pin!(quic);
656 tokio::pin!(websocket);
657
658 let mut quic_err = None;
659 let mut websocket_err = None;
660 let mut quic_done = false;
661 let mut websocket_done = false;
662
663 loop {
664 tokio::select! {
665 res = &mut quic, if !quic_done => {
666 match res {
667 Ok(session) => return Ok(TransportRace::Quic(session)),
668 Err(err) if err.is_auth() => return Err(err),
669 Err(err) => {
670 tracing::warn!(%err, "QUIC connection failed");
671 quic_err = Some(err);
672 quic_done = true;
673 }
674 }
675 }
676 res = &mut websocket, if !websocket_done => {
677 match res {
678 Some(Ok(session)) => return Ok(TransportRace::WebSocket(session)),
679 Some(Err(err)) if err.is_auth() => return Err(err),
680 Some(Err(err)) => {
681 tracing::warn!(%err, "WebSocket connection failed");
682 websocket_err = Some(err);
683 websocket_done = true;
684 }
685 None => {
686 websocket_done = true;
687 }
688 }
689 }
690 else => break,
691 }
692
693 if quic_done && websocket_done {
694 break;
695 }
696 }
697
698 match (quic_err, websocket_err) {
699 (Some(quic), Some(websocket)) => Err(Error::TransportRace {
700 quic: std::sync::Arc::new(quic),
701 websocket: std::sync::Arc::new(websocket),
702 }),
703 (Some(err), None) | (None, Some(err)) => Err(err),
704 (None, None) => Err(Error::ConnectFailed),
705 }
706}
707
708#[cfg(test)]
709mod tests {
710 use super::*;
711 use clap::Parser;
712
713 #[cfg(any(
714 feature = "noq",
715 feature = "quinn",
716 feature = "quiche",
717 feature = "iroh",
718 feature = "websocket",
719 feature = "tcp",
720 feature = "uds"
721 ))]
722 #[test]
723 fn setup_path_covers_the_uri_less_transports() {
724 let cases = [
727 ("unix:///run/moq.sock?path=/room", Some("/room")),
728 ("unix:///run/moq.sock?path=/room%3Fjwt%3Dabc", Some("/room?jwt=abc")),
731 ("unix:///run/moq.sock?path=", None),
732 ("unix:///run/moq.sock", None),
733 ("tcp://localhost:4443/room", Some("/room")),
734 ("tcp://localhost:4443/room?jwt=abc", Some("/room?jwt=abc")),
735 ("tcp://localhost:4443", None),
736 ("moqt://relay.example.com/anon", Some("/anon")),
739 ("moqt://relay.example.com/anon?jwt=abc", Some("/anon?jwt=abc")),
740 ("moql://relay.example.com/anon?jwt=abc", Some("/anon?jwt=abc")),
741 ("moqt://relay.example.com", None),
742 ("moqt://relay.example.com/anon?jwt=abc#pos:12", Some("/anon?jwt=abc")),
744 ("moqt://relay.example.com/anon#pos:12", Some("/anon")),
745 ("moqt://relay.example.com/anon?", Some("/anon")),
747 ("moqt://relay.example.com?", None),
748 ("https://relay.example.com/anon?jwt=abc", None),
751 ("http://relay.example.com/anon", None),
752 ("wss://relay.example.com/anon?jwt=abc", None),
753 ("iroh://k5lnrlndqpqcgh4d5nhbnbnhcyrgvw6ttxwrsvsu4nlt6foorxaa/anon", None),
755 ];
756
757 for (url, want) in cases {
758 let url = Url::parse(url).unwrap();
759 let got = setup_path(&url);
760 assert_eq!(got.as_deref(), want, "{url}");
761 }
762 }
763
764 #[cfg(any(
767 feature = "noq",
768 feature = "quinn",
769 feature = "quiche",
770 feature = "iroh",
771 feature = "websocket",
772 feature = "tcp",
773 feature = "uds"
774 ))]
775 #[test]
776 fn request_target_joins_the_path_and_query() {
777 const PEER: &str = "k5lnrlndqpqcgh4d5nhbnbnhcyrgvw6ttxwrsvsu4nlt6foorxaa";
778
779 let cases = [
780 (format!("iroh://{PEER}/room?jwt=abc"), Some("/room?jwt=abc")),
781 (format!("iroh://{PEER}/room"), Some("/room")),
782 (format!("iroh://{PEER}"), None),
783 (format!("iroh://{PEER}/"), Some("/")),
784 ];
785
786 for (url, want) in cases {
787 let url = Url::parse(&url).unwrap();
788 let got = request_target(&url);
789 assert_eq!(got.as_deref(), want, "{url}");
790 }
791 }
792
793 #[test]
794 fn test_toml_disable_verify_survives_update_from() {
795 let toml = r#"
796 tls.disable_verify = true
797 "#;
798
799 let mut config: ClientConfig = toml::from_str(toml).unwrap();
800 assert_eq!(config.tls.disable_verify, Some(true));
801
802 config.update_from(["test"]);
804 assert_eq!(config.tls.disable_verify, Some(true));
805 }
806
807 #[test]
808 fn test_cli_disable_verify_flag() {
809 let config = ClientConfig::parse_from(["test", "--client-tls-disable-verify"]);
810 assert_eq!(config.tls.disable_verify, Some(true));
811 }
812
813 #[test]
814 fn test_cli_disable_verify_explicit_false() {
815 let config = ClientConfig::parse_from(["test", "--client-tls-disable-verify=false"]);
816 assert_eq!(config.tls.disable_verify, Some(false));
817 }
818
819 #[test]
820 fn test_cli_disable_verify_explicit_true() {
821 let config = ClientConfig::parse_from(["test", "--client-tls-disable-verify=true"]);
822 assert_eq!(config.tls.disable_verify, Some(true));
823 }
824
825 #[test]
826 fn test_cli_deprecated_tls_flags_fold_into_canonical() {
827 let config = ClientConfig::parse_from(["test", "--tls-disable-verify=true", "--tls-fingerprint", "abcd1234"]);
831 assert_eq!(
832 config.tls.disable_verify, None,
833 "deprecated flag must not set the canonical field"
834 );
835 assert_eq!(config.tls.effective_disable_verify(), Some(true));
836 assert_eq!(config.tls.effective_fingerprint(), vec!["abcd1234"]);
837 }
838
839 #[test]
840 fn test_canonical_tls_flag_wins_over_deprecated() {
841 let config = ClientConfig::parse_from([
843 "test",
844 "--client-tls-disable-verify=false",
845 "--tls-disable-verify=true",
846 "--client-tls-fingerprint",
847 "aaaa",
848 "--tls-fingerprint",
849 "bbbb",
850 ]);
851 assert_eq!(config.tls.effective_disable_verify(), Some(false));
852 assert_eq!(config.tls.effective_fingerprint(), vec!["aaaa", "bbbb"]);
853 }
854
855 #[test]
856 fn test_cli_no_disable_verify() {
857 let config = ClientConfig::parse_from(["test"]);
858 assert_eq!(config.tls.disable_verify, None);
859 }
860
861 #[test]
862 fn test_toml_failover_delay_survives_update_from() {
863 let toml = r#"
864 failover_delay = "1s"
865 "#;
866
867 let mut config: ClientConfig = toml::from_str(toml).unwrap();
868 assert_eq!(config.failover_delay, Some(std::time::Duration::from_secs(1)));
869
870 config.update_from(["test"]);
872 assert_eq!(config.failover_delay, Some(std::time::Duration::from_secs(1)));
873 }
874
875 #[test]
876 fn test_cli_failover_delay() {
877 let config = ClientConfig::parse_from(["test", "--client-failover-delay", "50ms"]);
878 assert_eq!(config.failover_delay, Some(std::time::Duration::from_millis(50)));
879 }
880
881 #[test]
882 fn test_toml_fingerprint_survives_update_from() {
883 let toml = r#"
884 tls.fingerprint = ["abcd1234", "ef567890"]
885 "#;
886
887 let mut config: ClientConfig = toml::from_str(toml).unwrap();
888 assert_eq!(config.tls.fingerprint, vec!["abcd1234", "ef567890"]);
889
890 config.update_from(["test"]);
892 assert_eq!(config.tls.fingerprint, vec!["abcd1234", "ef567890"]);
893 }
894
895 #[test]
896 fn test_toml_fingerprint_accepts_single_string() {
897 let toml = r#"
898 tls.fingerprint = "abcd1234"
899 "#;
900
901 let config: ClientConfig = toml::from_str(toml).unwrap();
902 assert_eq!(config.tls.fingerprint, vec!["abcd1234"]);
903 }
904
905 #[test]
906 fn test_cli_fingerprint() {
907 let config = ClientConfig::parse_from(["test", "--client-tls-fingerprint", "abcd1234"]);
908 assert_eq!(config.tls.fingerprint, vec!["abcd1234"]);
909 }
910
911 #[test]
912 fn test_toml_version_survives_update_from() {
913 let toml = r#"
914 version = ["moq-lite-02"]
915 "#;
916
917 let mut config: ClientConfig = toml::from_str(toml).unwrap();
918 assert_eq!(config.version, vec!["moq-lite-02".parse::<moq_net::Version>().unwrap()]);
919
920 config.update_from(["test"]);
922 assert_eq!(config.version, vec!["moq-lite-02".parse::<moq_net::Version>().unwrap()]);
923 }
924
925 #[test]
926 fn test_cli_version() {
927 let config = ClientConfig::parse_from(["test", "--client-version", "moq-lite-03"]);
928 assert_eq!(config.version, vec!["moq-lite-03".parse::<moq_net::Version>().unwrap()]);
929 }
930
931 #[test]
932 fn test_toml_connect_survives_update_from() {
933 let toml = r#"
934 connect = "https://relay.example.com/anon"
935 "#;
936
937 let mut config: ClientConfig = toml::from_str(toml).unwrap();
938 assert_eq!(
939 config.connect.as_ref().unwrap().as_str(),
940 "https://relay.example.com/anon"
941 );
942
943 config.update_from(["test"]);
945 assert_eq!(
946 config.connect.as_ref().unwrap().as_str(),
947 "https://relay.example.com/anon"
948 );
949 }
950
951 #[test]
952 fn test_cli_connect() {
953 let config = ClientConfig::parse_from(["test", "--client-connect", "https://relay.example.com/anon"]);
954 assert_eq!(
955 config.connect.as_ref().unwrap().as_str(),
956 "https://relay.example.com/anon"
957 );
958 }
959
960 #[test]
961 fn test_toml_host_name_survives_update_from() {
962 let toml = r#"
963 tls.host_name = "example.host"
964 "#;
965
966 let mut config: ClientConfig = toml::from_str(toml).unwrap();
967 assert_eq!(config.tls.host_name.as_deref(), Some("example.host"));
968
969 config.update_from(["test"]);
971 assert_eq!(config.tls.host_name.as_deref(), Some("example.host"));
972 }
973
974 #[test]
975 fn test_cli_host_name() {
976 let config = ClientConfig::parse_from(["test", "--client-tls-host-name", "override.example"]);
977 assert_eq!(config.tls.host_name.as_deref(), Some("override.example"));
978 }
979
980 #[test]
981 fn test_cli_no_version_defaults_to_all() {
982 let config = ClientConfig::parse_from(["test"]);
983 assert!(config.version.is_empty());
984 assert_eq!(config.versions().alpns().len(), moq_net::ALPNS.len());
986 }
987
988 #[cfg(feature = "websocket")]
989 #[tokio::test]
990 async fn race_transport_connect_stops_on_quic_auth_error() {
991 let quic = async { Err::<usize, _>(crate::ConnectError::Unauthorized.into()) };
992 let websocket = async {
993 tokio::task::yield_now().await;
995 Some(Ok(1usize))
996 };
997
998 let err = super::race_transport_connect(quic, websocket).await.unwrap_err();
999 assert_eq!(err.connect_error(), Some(crate::ConnectError::Unauthorized));
1000 }
1001
1002 #[cfg(feature = "websocket")]
1003 #[tokio::test]
1004 async fn race_transport_connect_keeps_websocket_after_quic_non_auth_error() {
1005 let quic = async { Err::<usize, _>(Error::ConnectFailed) };
1006 let websocket = async { Some(Ok(7usize)) };
1007
1008 let value = super::race_transport_connect(quic, websocket).await.unwrap();
1009 assert_eq!(value, super::TransportRace::WebSocket(7));
1010 }
1011
1012 #[cfg(feature = "websocket")]
1013 #[tokio::test]
1014 async fn race_transport_connect_returns_when_quic_transport_connects() {
1015 let quic = async { Ok("quic") };
1016 let websocket = std::future::pending::<Option<crate::Result<&str>>>();
1017
1018 let value = tokio::time::timeout(
1019 std::time::Duration::from_secs(1),
1020 super::race_transport_connect(quic, websocket),
1021 )
1022 .await
1023 .expect("race waited for WebSocket after QUIC transport connected")
1024 .unwrap();
1025 assert_eq!(value, super::TransportRace::Quic("quic"));
1026 }
1027
1028 #[test]
1029 fn connect_timeout_defaults_to_thirty_seconds() {
1030 let config = ClientConfig::parse_from(["test"]);
1031 assert_eq!(config.timeout, None);
1032 assert_eq!(config.resolved_connect_timeout(), DEFAULT_CONNECT_TIMEOUT);
1033 }
1034
1035 #[cfg(feature = "websocket")]
1043 #[tokio::test]
1044 async fn connect_times_out_against_a_peer_that_never_speaks() {
1045 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1046 let addr = listener.local_addr().unwrap();
1047
1048 let timeout = DEFAULT_CONNECT_TIMEOUT;
1049 let mut config = ClientConfig {
1050 timeout: Some(timeout),
1051 ..Default::default()
1052 };
1053 config.websocket.delay = Some(std::time::Duration::ZERO);
1054 let client = config.init().unwrap();
1055
1056 let url: Url = format!("https://127.0.0.1:{}/", addr.port()).parse().unwrap();
1059
1060 let mut attempt = Box::pin(client.connect(url));
1061 let _silent = tokio::select! {
1062 res = &mut attempt => match res {
1063 Err(err) => panic!("connect failed before the silent peer accepted it: {err}"),
1064 Ok(_) => panic!("connected to a peer that never spoke"),
1065 },
1066 res = listener.accept() => res.unwrap().0,
1067 };
1068
1069 tokio::time::pause();
1072 tokio::time::advance(timeout).await;
1073
1074 let err = match attempt.await {
1075 Err(err) => err,
1076 Ok(_) => panic!("connected to a peer that never spoke"),
1077 };
1078
1079 assert!(matches!(err, Error::ConnectTimeout(_)), "unexpected error: {err}");
1080 }
1081}