Make native DNS cache freshness explicit (#227)

Add a system-only native_cache_secs option for configurable OS/NSS application memoization while preserving the existing 60-second implicit default and uncached explicit system behavior. Disable completed-cache reads and writes for zero windows, reject invalid DNS options, and document freshness and lookup-sharing semantics.
This commit is contained in:
cfal
2026-10-26 14:56:33 +07:00
parent b821c56f86
commit f94fa61e36
9 changed files with 422 additions and 36 deletions
+45
View File
@@ -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:
+12
View File
@@ -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:
+55
View File
@@ -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<SocketAddr>);
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<dyn Resolver> = 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] {
+91 -1
View File
@@ -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<Option<u32>, 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<u32>,
},
}
@@ -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<u32> {
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::<DnsServerSpec>(&yaml)
.unwrap()
.native_cache_secs(),
expected
);
assert_eq!(
serde_json::from_str::<DnsServerSpec>(&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::<DnsServerSpec>(&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::<DnsServerSpec>(yaml).is_err(),
"accepted {yaml}"
);
}
assert!(
serde_yaml::from_str::<DnsConfig>("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());
+58
View File
@@ -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<ClientChain> = 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,
},
]),
}),
+51 -2
View File
@@ -537,7 +537,13 @@ fn build_resolver_with_limits(
let resolver: Arc<dyn Resolver> = 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::<std::net::SocketAddr>().unwrap()]
);
}
}
#[tokio::test]
async fn omitted_servers_keeps_default_cache_and_retired_pools_freeze_on_shutdown() {
let configs = crate::config::create_server_configs(
+3
View File
@@ -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,
}
}
}
+16 -8
View File
@@ -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<Arc<[SocketAddr]>> {
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<dyn Resolver>, 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
}
};
+91 -25
View File
@@ -55,8 +55,7 @@ impl FakeLookup {
fn fixture(max_age_secs: u64) -> (CachingNativeResolver, Arc<FakeLookup>) {
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::<SocketAddr>().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::<SocketAddr>().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);