diff --git a/CONFIG.md b/CONFIG.md index b6d6878f..8bc48ef2 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -10,6 +10,7 @@ shoes uses YAML configuration files. Multiple configuration types can be combine - [WireGuard](#wireguard) - [Client Config](#client-config) - [Client Protocols](#client-protocols) +- [Native DNS Freshness](#native-dns-freshness) - [Fake DNS](#fake-dns) - [Rules System](#rules-system) - [Named Groups](#named-groups) @@ -1120,6 +1121,50 @@ HTTP/2 PING watchdog. Peer stream limits and timeouts still apply. In particular Caddy v2.11.7 defaults to a one-minute request-body idle timeout, which can end a quiet CONNECT tunnel; connection-level PINGs do not reset that timeout. +## Native DNS Freshness + +Omitting a listener's `dns` or leaving `dns.servers` empty uses native OS/NSS +hostname resolution with a **60-second application memoization window**. The +same default resolver serves controller probes. The window starts when a +successful lookup finishes; an entry expires at the window boundary. All returned +usable addresses and their original order are retained. +Errors and empty results are not cached, and expired answers are not served on +failure. Each cache has a lazy, bounded 10,000-entry LRU keyed by exact hostname; +results are shared across ports, with each caller receiving its requested port. +Eviction can cause earlier refresh. Concurrent misses share a lookup, up to +1,024 participating callers per resolver. Excess callers look up independently; +this is not an OS lookup concurrency cap. Cancelling the owner makes surviving +callers retry within their remaining deadlines. No background refresh is added, +and already-running OS calls cannot be stopped. + +Explicit `dns: {servers: system}` continues to use OS/NSS lookup **without +application memoization or concurrent lookup sharing**. To select a different +fixed window, use a system server options object, inline or in a named DNS group: + +```yaml +dns: + servers: + url: system + native_cache_secs: 15 +``` + +`native_cache_secs` accepts an integer from 0 to 4294967295 seconds. Omitted or +`0` disables memoization for this explicit system entry. It is rejected on +non-system upstreams, including when set to `0`; unknown options and invalid +values are rejected. The option belongs to the server options object, not the +outer `dns` object. Existing `timeout_secs` remains a lookup deadline, not cache +freshness; `connect_timeout_secs` and `attempts` do not control native caching. + +This is a load/freshness trade-off, **not DNS TTL compliance**: the OS lookup API +does not provide record TTLs. Shorter windows increase OS lookups; disabling the +Shoes cache cannot bypass caches in the OS, NSS modules, or upstream resolvers. +Native hosts-file/NSS behavior is preserved. New flows can see updated answers +after expiry; established flows retain their pinned destination. This setting +controls native hostname lookup caching, not every DNS-related cache. Hickory +caches and Fake-IP mappings/TTLs are unchanged. + +See [the DNS tuning example](examples/dns_tuning.yaml). + ## Fake DNS Fake DNS is opt-in on a server (including WireGuard) or TUN configuration: diff --git a/examples/dns_tuning.yaml b/examples/dns_tuning.yaml index 3632c010..60f4136d 100644 --- a/examples/dns_tuning.yaml +++ b/examples/dns_tuning.yaml @@ -20,6 +20,18 @@ # These defaults leave configured upstream resolvers, raw DNS exchange, and # bootstrap unchanged. +# Native OS/NSS lookup memoization is separate from hickory tuning and DNS TTLs. +# Omitted listener dns defaults to 60 seconds. Explicit system defaults to no cache. +- dns_group: native-short-cache + dns_servers: + - url: system + native_cache_secs: 15 + +- dns_group: native-no-cache + dns_servers: + - url: system + native_cache_secs: 0 + # Direct DNS group with fast-fail tuning. - dns_group: tuned-direct-dns dns_servers: diff --git a/src/client_proxy_selector.rs b/src/client_proxy_selector.rs index c4ce24e5..b8db3331 100644 --- a/src/client_proxy_selector.rs +++ b/src/client_proxy_selector.rs @@ -1318,6 +1318,61 @@ mod tests { )); } + #[tokio::test(start_paused = true)] + async fn native_cache_refresh_changes_new_flow_routing_without_retargeting_pinned_flow() { + #[derive(Debug)] + struct ChangingLookup(parking_lot::Mutex); + impl Resolver for ChangingLookup { + fn resolve_location(&self, _: &NetLocation) -> crate::resolver::ResolveFuture { + let address = *self.0.lock(); + Box::pin(async move { Ok(vec![address]) }) + } + } + + let first = "192.0.2.1:443".parse().unwrap(); + let lookup = Arc::new(ChangingLookup(parking_lot::Mutex::new(first))); + let resolver: Arc = Arc::new( + crate::resolver::CachingNativeResolver::with_lookup(lookup.clone(), 60), + ); + let selector = selector_with_cache(vec![ + block_rule(vec!["127.0.0.0/8"]), + allow_rule(vec!["0.0.0.0/0"], "direct"), + ]); + let hostname = NetLocation::from_str("changing.test:443", None).unwrap(); + let ConnectDecision::Allow { + mut remote_location, + .. + } = selector + .judge(hostname.clone().into(), &resolver) + .await + .unwrap() + else { + panic!("initial address should be allowed"); + }; + assert_eq!(remote_location.resolved_addr(), Some(first)); + *lookup.0.lock() = "127.0.0.1:443".parse().unwrap(); + tokio::time::advance(std::time::Duration::from_secs(59)).await; + assert!(matches!( + selector + .judge(hostname.clone().into(), &resolver) + .await + .unwrap(), + ConnectDecision::Allow { .. } + )); + tokio::time::advance(std::time::Duration::from_secs(1)).await; + assert!(matches!( + selector.judge(hostname.into(), &resolver).await.unwrap(), + ConnectDecision::Block + )); + assert_eq!( + resolve_location(&mut remote_location, &resolver) + .await + .unwrap(), + first + ); + assert_eq!(selector.cache_size(), 0); + } + #[tokio::test] async fn domain_rules_agree_with_cache_on_case_and_root_dot() { for capacity in [0, 10] { diff --git a/src/config/types/dns.rs b/src/config/types/dns.rs index ed36821b..a5ba886c 100644 --- a/src/config/types/dns.rs +++ b/src/config/types/dns.rs @@ -23,9 +23,16 @@ fn default_attempts() -> usize { 1 } +/// Rejects explicit null; Serde defaults an omitted field to None. +fn deserialize_native_cache_secs<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + u32::deserialize(deserializer).map(Some) +} + /// A DNS server specification in config. #[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(untagged)] +#[serde(untagged, deny_unknown_fields)] #[allow(clippy::large_enum_variant)] pub enum DnsServerSpec { /// Simple URL string: "system", "udp://8.8.8.8", etc. @@ -59,6 +66,13 @@ pub enum DnsServerSpec { /// Number of retry attempts for failed queries. Defaults to 1. #[serde(default = "default_attempts")] attempts: usize, + /// Fixed OS/NSS lookup memoization window. System only; omitted or 0 disables it. + #[serde( + default, + deserialize_with = "deserialize_native_cache_secs", + skip_serializing_if = "Option::is_none" + )] + native_cache_secs: Option, }, } @@ -158,6 +172,18 @@ impl DnsServerSpec { default_attempts() } } + + /// Keeps omission distinct from an explicit zero for system-only validation. + pub fn native_cache_secs(&self) -> Option { + if let Self::WithOptions { + native_cache_secs, .. + } = self + { + *native_cache_secs + } else { + None + } + } } /// DNS group configuration. @@ -218,6 +244,8 @@ pub struct ExpandedDnsSpec { pub connect_timeout_secs: u32, /// Number of retry attempts for failed queries. pub attempts: usize, + /// Fixed system lookup memoization window in seconds. 0 disables it. + pub native_cache_secs: u32, } /// A DNS group with all specs expanded. @@ -233,6 +261,67 @@ pub struct ExpandedDnsGroup { mod tests { use super::*; + #[test] + fn native_cache_defaults_and_explicit_values_round_trip() { + for (yaml, expected) in [ + ("system", None), + ("url: system", None), + ("url: system\nnative_cache_secs: 0", Some(0)), + ("url: system\nnative_cache_secs: 7", Some(7)), + ("url: system\nnative_cache_secs: 4294967295", Some(u32::MAX)), + ] { + let spec: DnsServerSpec = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(spec.native_cache_secs(), expected); + let yaml = serde_yaml::to_string(&spec).unwrap(); + let json = serde_json::to_string(&spec).unwrap(); + assert_eq!( + serde_yaml::from_str::(&yaml) + .unwrap() + .native_cache_secs(), + expected + ); + assert_eq!( + serde_json::from_str::(&json) + .unwrap() + .native_cache_secs(), + expected + ); + } + } + + #[test] + fn native_cache_rejects_invalid_values_and_unknown_fields() { + for value in [ + "-1", + "1.5", + "4294967296", + "null", + "true", + "[]", + "{}", + "'60'", + ] { + let yaml = format!("url: system\nnative_cache_secs: {value}"); + assert!( + serde_yaml::from_str::(&yaml).is_err(), + "accepted {yaml}" + ); + } + for yaml in [ + "url: system\nnative_cache_sec: 0", + "url: system\nnative_cache_secs: 0\nextra: true", + "url: udp://192.0.2.1\nextra: true", + ] { + assert!( + serde_yaml::from_str::(yaml).is_err(), + "accepted {yaml}" + ); + } + assert!( + serde_yaml::from_str::("servers: system\nnative_cache_secs: 0").is_err() + ); + } + #[test] fn test_dns_server_spec_simple() { let yaml = r#"system"#; @@ -390,6 +479,7 @@ servers: my-dns-group timeout_secs: default_timeout_secs(), connect_timeout_secs: default_connect_timeout_secs(), attempts: default_attempts(), + native_cache_secs: None, }; assert!(!spec.as_group_ref().is_some()); assert!(spec.as_group_ref().is_none()); diff --git a/src/config/validate.rs b/src/config/validate.rs index ebab3c14..f401d952 100644 --- a/src/config/validate.rs +++ b/src/config/validate.rs @@ -811,6 +811,7 @@ fn validate_dns_group_ref( /// Expands client chains (resolves group refs to configs) and validates: /// - Inline client configs (PEMs, etc.) /// - Protocol compatibility (system doesn't support chains, UDP/H3 only direct chains) +/// - System-only use of native_cache_secs /// - Bootstrap URL validity (must be a known group or valid IP-only URL) fn expand_dns_specs( specs: &[DnsServerSpec], @@ -829,6 +830,12 @@ fn expand_dns_specs( let parsed_url = ParsedDnsUrl::parse_with_server_name(url_str, server_name_override) .map_err(|e| std::io::Error::other(e.to_string()))?; + if spec.native_cache_secs().is_some() && !matches!(parsed_url, ParsedDnsUrl::System) { + return Err(std::io::Error::other( + "native_cache_secs is only supported for system DNS resolver", + )); + } + // Expand and validate client chains let client_chains_raw = spec.client_chains(); let mut expanded_chains: Vec = Vec::new(); @@ -911,6 +918,7 @@ fn expand_dns_specs( timeout_secs: spec.timeout_secs(), connect_timeout_secs: spec.connect_timeout_secs(), attempts: spec.attempts(), + native_cache_secs: spec.native_cache_secs().unwrap_or(0), }); } @@ -3550,6 +3558,7 @@ mod tests { timeout_secs: 10, connect_timeout_secs: 5, attempts: 1, + native_cache_secs: None, }), })]; @@ -3562,6 +3571,47 @@ mod tests { ); } + #[test] + fn test_dns_native_cache_only_accepts_system_specs() { + for url in [ + "system", + "udp://192.0.2.1", + "tcp://192.0.2.1", + "tls://192.0.2.1", + "https://192.0.2.1/dns-query", + "h3://192.0.2.1/dns-query", + ] { + for seconds in [0, 7] { + let yaml = format!( + "- dns_group: native\n dns_servers: [{{url: '{url}', native_cache_secs: {seconds}}}]\n- dns_group: composed\n dns_servers: native\n" + ); + let result = create_server_configs(serde_yaml::from_str(&yaml).unwrap()); + if url == "system" { + let config = result.unwrap(); + assert_eq!(config.dns_groups.len(), 2); + for group in config.dns_groups { + assert_eq!(group.specs[0].native_cache_secs, seconds); + } + } else { + let error = result.err().expect("non-system cache option must fail"); + assert!( + error + .to_string() + .contains("native_cache_secs is only supported for system") + ); + } + } + } + } + + #[test] + fn test_dns_tuning_example_validates() { + create_server_configs( + serde_yaml::from_str(include_str!("../../examples/dns_tuning.yaml")).unwrap(), + ) + .unwrap(); + } + #[tokio::test] async fn test_dns_udp_with_proxy_chain_rejected() { use crate::config::types::{ClientConfigGroup, ClientProxyConfig}; @@ -3596,6 +3646,7 @@ mod tests { timeout_secs: 10, connect_timeout_secs: 5, attempts: 1, + native_cache_secs: None, }), }), ]; @@ -3635,6 +3686,7 @@ mod tests { timeout_secs: 10, connect_timeout_secs: 5, attempts: 1, + native_cache_secs: None, }), }), ]; @@ -3676,6 +3728,7 @@ mod tests { timeout_secs: 10, connect_timeout_secs: 5, attempts: 1, + native_cache_secs: None, }), }), ]; @@ -3715,6 +3768,7 @@ mod tests { timeout_secs: 10, connect_timeout_secs: 5, attempts: 1, + native_cache_secs: None, }), }), ]; @@ -3747,6 +3801,7 @@ mod tests { timeout_secs: 10, connect_timeout_secs: 5, attempts: 1, + native_cache_secs: None, }), }), ]; @@ -3780,6 +3835,7 @@ mod tests { timeout_secs: 10, connect_timeout_secs: 5, attempts: 1, + native_cache_secs: None, }), }), ]; @@ -3813,6 +3869,7 @@ mod tests { timeout_secs: 10, connect_timeout_secs: 5, attempts: 1, + native_cache_secs: None, }), }), ]; @@ -3958,6 +4015,7 @@ mod tests { timeout_secs: 10, connect_timeout_secs: 5, attempts: 1, + native_cache_secs: None, }, ]), }), diff --git a/src/dns/builder.rs b/src/dns/builder.rs index fc21a090..58cab94a 100644 --- a/src/dns/builder.rs +++ b/src/dns/builder.rs @@ -537,7 +537,13 @@ fn build_resolver_with_limits( let resolver: Arc = match entry.server { ParsedDnsServer::System => wrap_resolver( SystemResolver::new( - Arc::new(NativeResolver::new()), + if entry.native_cache_secs == 0 { + Arc::new(NativeResolver::new()) + } else { + Arc::new(CachingNativeResolver::with_timeout( + entry.native_cache_secs.into(), + )) + }, forbidden.clone(), options.request_timeout.unwrap_or(Duration::from_secs(5)), ), @@ -952,7 +958,7 @@ async fn build_entry_and_plan( .to_parsed_server(resolved_ip) .map_err(|e| std::io::Error::other(e.to_string()))?; - let entry = ParsedDnsServerEntry::new( + let mut entry = ParsedDnsServerEntry::new( server, chain_group, bootstrap_resolver, @@ -961,6 +967,7 @@ async fn build_entry_and_plan( spec.connect_timeout_secs, spec.attempts, ); + entry.native_cache_secs = spec.native_cache_secs; Ok((entry, plan)) } @@ -1009,6 +1016,7 @@ mod tests { timeout_secs: 5, connect_timeout_secs: 5, attempts: 1, + native_cache_secs: 0, } } @@ -1021,6 +1029,47 @@ mod tests { }] } + #[tokio::test] + async fn native_cache_policy_distinguishes_implicit_and_explicit_system() { + for (dns, window) in [ + ("", Some(60)), + ("dns: {}", Some(60)), + ("dns: {servers: []}", Some(60)), + ("dns: {servers: system}", None), + ("dns: {servers: {url: system}}", None), + ("dns: {servers: {url: system, native_cache_secs: 0}}", None), + ( + "dns: {servers: {url: system, native_cache_secs: 7}}", + Some(7), + ), + ] { + let yaml = + format!("- address: '127.0.0.1:1080'\n protocol: {{type: socks}}\n {dns}\n"); + let configs = + crate::config::create_server_configs(serde_yaml::from_str(&yaml).unwrap()).unwrap(); + let mut registry = build_dns_registry(configs.dns_groups).await.unwrap(); + let crate::config::Config::Server(server) = &configs.configs[0] else { + panic!("expected server") + }; + let resolver = registry.get_for_server(server.dns.as_ref()); + let debug = format!("{resolver:?}"); + assert!(debug.contains("SystemResolver"), "{debug}"); + match window { + Some(seconds) => { + let expected = + format!("CachingNativeResolver {{ result_timeout_secs: {seconds} }}"); + assert!(debug.contains(&expected), "{dns}: {debug}"); + } + None => assert!(!debug.contains("CachingNativeResolver"), "{dns}: {debug}"), + } + let literal = crate::address::NetLocation::from_str("127.0.0.1:443", None).unwrap(); + assert_eq!( + resolver.resolve_location(&literal).await.unwrap(), + vec!["127.0.0.1:443".parse::().unwrap()] + ); + } + } + #[tokio::test] async fn omitted_servers_keeps_default_cache_and_retired_pools_freeze_on_shutdown() { let configs = crate::config::create_server_configs( diff --git a/src/dns/parsed.rs b/src/dns/parsed.rs index 341a1a4b..d8621c77 100644 --- a/src/dns/parsed.rs +++ b/src/dns/parsed.rs @@ -56,6 +56,8 @@ pub struct ParsedDnsServerEntry { pub connect_timeout_secs: u32, /// Number of retry attempts for failed queries. pub attempts: usize, + /// Fixed system lookup memoization window in seconds. 0 disables it. + pub native_cache_secs: u32, } impl ParsedDnsServerEntry { @@ -76,6 +78,7 @@ impl ParsedDnsServerEntry { timeout_secs, connect_timeout_secs, attempts, + native_cache_secs: 0, } } } diff --git a/src/resolver.rs b/src/resolver.rs index 8a816694..a634a398 100644 --- a/src/resolver.rs +++ b/src/resolver.rs @@ -471,9 +471,9 @@ pub async fn resolve_location( } /// Native resolver with application-level caching. -/// The default for servers without configured DNS servers. OS lookups expose no DNS -/// TTL; successful results are reused for at most 60 seconds by default, measured -/// from lookup completion. Errors and empty results are never cached. +/// Used by implicit native resolution and opt-in caching on explicit `system` entries. +/// OS lookups expose no DNS TTL; successful results are reused for at most 60 seconds +/// by default, measured from lookup completion. Errors and empty results are never cached. /// /// At most 1,024 callers participate in coalesced misses (owners plus subscribers). /// Excess callers perform independent lookups, preserving existing failure @@ -501,6 +501,9 @@ struct CachedResolveResult { impl NativeResolverState { fn cached(&mut self, address: &Address, max_age: Duration) -> Option> { + if max_age.is_zero() { + return None; + } if let Some(cached) = self.cache.get(address) && cached.completed_at.elapsed() < max_age { @@ -523,9 +526,9 @@ enum NativeLookupRole { } impl NativeLookupOwner { - fn publish(mut self, result: &NativeLookupResult) { + fn publish(mut self, result: &NativeLookupResult, cache_enabled: bool) { let mut state = self.state.lock(); - if let Ok(addrs) = result { + if cache_enabled && let Ok(addrs) = result { state.cache.put( self.address.clone(), CachedResolveResult { @@ -590,15 +593,19 @@ impl CachingNativeResolver { } /// Sets the maximum successful result age, not a lookup deadline or DNS TTL. - /// Zero disables completed-result reuse but still coalesces concurrent misses. + /// Zero disables completed-result caching but still coalesces concurrent misses. pub fn with_timeout(result_timeout_secs: u64) -> Self { + Self::with_lookup(Arc::new(NativeResolver::new()), result_timeout_secs) + } + + pub(crate) fn with_lookup(lookup: Arc, result_timeout_secs: u64) -> Self { Self { state: Arc::new(Mutex::new(NativeResolverState { cache: bounded_lru(CACHING_NATIVE_RESOLVER_CACHE_CAPACITY), inflight: HashMap::new(), })), participants: Arc::new(Semaphore::new(CACHING_NATIVE_RESOLVER_MAX_PARTICIPANTS)), - lookup: Arc::new(NativeResolver::new()), + lookup, result_timeout_secs, } } @@ -613,6 +620,7 @@ impl Default for CachingNativeResolver { impl Resolver for CachingNativeResolver { fn resolve_location(&self, location: &NetLocation) -> ResolveFuture { let max_age = Duration::from_secs(self.result_timeout_secs); + let cache_enabled = self.result_timeout_secs > 0; let caller_port = location.port(); if let Some(addrs) = self.state.lock().cached(location.address(), max_age) { let addrs = native_addrs_with_port(&addrs, caller_port); @@ -677,7 +685,7 @@ impl Resolver for CachingNativeResolver { } }) .map_err(Arc::new); - owner.publish(&result); + owner.publish(&result, cache_enabled); result } }; diff --git a/src/resolver/native_tests.rs b/src/resolver/native_tests.rs index 8e4fb685..234bdcc3 100644 --- a/src/resolver/native_tests.rs +++ b/src/resolver/native_tests.rs @@ -55,8 +55,7 @@ impl FakeLookup { fn fixture(max_age_secs: u64) -> (CachingNativeResolver, Arc) { let lookup = Arc::new(FakeLookup::default()); - let mut resolver = CachingNativeResolver::with_timeout(max_age_secs); - resolver.lookup = lookup.clone(); + let resolver = CachingNativeResolver::with_lookup(lookup.clone(), max_age_secs); (resolver, lookup) } @@ -458,29 +457,31 @@ async fn failures_share_errno_kind_and_message_without_negative_caching() { #[tokio::test(start_paused = true)] async fn result_age_starts_at_completion_and_hits_do_not_extend_it() { assert_eq!(CachingNativeResolver::DEFAULT_RESULT_TIMEOUT_SECS, 60); - let (resolver, lookup) = fixture(60); - let mut first = resolver.resolve_location(&location(80)); - assert!(futures::poll!(&mut first).is_pending()); - tokio::time::advance(Duration::from_secs(120)).await; - lookup.reply(Ok(candidates())); - first.await.unwrap(); - tokio::time::advance(Duration::from_secs(59)).await; - assert_eq!( - ready_result(resolver.resolve_location(&location(443))).unwrap(), - native_addrs_with_port(&candidates(), 443) - ); - assert_eq!(lookup.calls(), 1); - tokio::time::advance(Duration::from_secs(1)).await; - let mut refreshed = resolver.resolve_location(&location(443)); - assert!(futures::poll!(&mut refreshed).is_pending()); - assert!(resolver.state.lock().cache.is_empty()); - lookup.reply(Ok(vec!["192.0.2.99:0".parse().unwrap()])); - assert_eq!( - refreshed.await.unwrap(), - vec!["192.0.2.99:443".parse::().unwrap()] - ); - assert_eq!(lookup.calls(), 2); - assert_idle(&resolver, &lookup); + for window in [CachingNativeResolver::DEFAULT_RESULT_TIMEOUT_SECS, 7] { + let (resolver, lookup) = fixture(window); + let mut first = resolver.resolve_location(&location(80)); + assert!(futures::poll!(&mut first).is_pending()); + tokio::time::advance(Duration::from_secs(120)).await; + lookup.reply(Ok(candidates())); + first.await.unwrap(); + tokio::time::advance(Duration::from_secs(window) - Duration::from_nanos(1)).await; + assert_eq!( + ready_result(resolver.resolve_location(&location(443))).unwrap(), + native_addrs_with_port(&candidates(), 443) + ); + assert_eq!(lookup.calls(), 1); + tokio::time::advance(Duration::from_nanos(1)).await; + let mut refreshed = resolver.resolve_location(&location(443)); + assert!(futures::poll!(&mut refreshed).is_pending()); + assert!(resolver.state.lock().cache.is_empty()); + lookup.reply(Ok(vec!["192.0.2.99:0".parse().unwrap()])); + assert_eq!( + refreshed.await.unwrap(), + vec!["192.0.2.99:443".parse::().unwrap()] + ); + assert_eq!(lookup.calls(), 2); + assert_idle(&resolver, &lookup); + } } #[tokio::test(start_paused = true)] @@ -495,11 +496,76 @@ async fn zero_result_age_disables_reuse_but_coalesces_misses() { first.await.unwrap(); second.await.unwrap(); assert_eq!(lookup.calls(), 1); + assert!(resolver.state.lock().cache.is_empty()); complete_lookup(&resolver, &lookup, 80).await; assert_eq!(lookup.calls(), 2); + assert!(resolver.state.lock().cache.is_empty()); assert_idle(&resolver, &lookup); } +#[tokio::test(start_paused = true)] +async fn zero_result_age_does_not_read_or_replace_seeded_cache_entries() { + let (resolver, lookup) = fixture(0); + let seeded: Arc<[SocketAddr]> = Arc::from(vec!["192.0.2.99:0".parse().unwrap()]); + resolver.state.lock().cache.put( + location(0).address().clone(), + CachedResolveResult { + completed_at: tokio::time::Instant::now(), + addrs: seeded.clone(), + }, + ); + complete_lookup(&resolver, &lookup, 80).await; + complete_lookup(&resolver, &lookup, 443).await; + assert_eq!(lookup.calls(), 2); + assert!(Arc::ptr_eq( + &resolver + .state + .lock() + .cache + .peek(location(0).address()) + .unwrap() + .addrs, + &seeded, + )); + assert_idle(&resolver, &lookup); +} + +#[tokio::test(start_paused = true)] +async fn expired_answers_are_not_served_when_refresh_fails() { + let (resolver, lookup) = fixture(7); + complete_lookup(&resolver, &lookup, 80).await; + tokio::time::advance(Duration::from_secs(7)).await; + for failure in [ + Err(std::io::Error::new(std::io::ErrorKind::NotFound, "missing")), + Ok(vec![]), + ] { + let mut refresh = resolver.resolve_location(&location(443)); + assert!(futures::poll!(&mut refresh).is_pending()); + lookup.reply(failure); + assert!(refresh.await.is_err()); + assert!(resolver.state.lock().cache.is_empty()); + assert_idle(&resolver, &lookup); + } + complete_lookup(&resolver, &lookup, 443).await; + assert_eq!(lookup.calls(), 4); + assert_idle(&resolver, &lookup); +} + +#[tokio::test] +async fn zero_result_age_overflow_completion_does_not_cache() { + let (resolver, lookup) = fixture(0); + let _permits = resolver + .participants + .acquire_many(CACHING_NATIVE_RESOLVER_MAX_PARTICIPANTS as u32) + .await + .unwrap(); + complete_lookup(&resolver, &lookup, 80).await; + assert_eq!(lookup.calls(), 1); + assert!(resolver.state.lock().cache.is_empty()); + assert!(resolver.state.lock().inflight.is_empty()); + assert_eq!(lookup.active.load(Ordering::Relaxed), 0); +} + #[tokio::test] async fn cache_evicts_least_recent_host_and_keeps_exact_host_identity() { let (resolver, lookup) = fixture(60);