mirror of
https://github.com/cfal/shoes.git
synced 2026-11-06 08:14:11 +00:00
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:
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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());
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user