Skip to content

Commit 32d7d2c

Browse files
authored
Defer fallback DNS initialization for literal targets (#949)
* Initialize the fallback DNS resolver only when needed * Record lazy DNS startup benchmark results * docs(perf): remeasure lazy DNS against merged Tokio master * docs(perf): verify lazy DNS on current master
1 parent 67c08a3 commit 32d7d2c

3 files changed

Lines changed: 281 additions & 11 deletions

File tree

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
{
2+
"metadata": {
3+
"base": "67c08a33100362c54b56fde784b0fd234f70fa11",
4+
"candidate": "a05592a8a88b77ad85d961137943dfadfe98b612",
5+
"environment": "Linux-7.2.6-1-cachyos-x86_64-with-glibc2.44",
6+
"rust": "rustc 1.98.1 (48a229cea 2026-09-01) (Arch Linux rust 1:1.98.1-1.1)",
7+
"hosts_bytes": 2760051,
8+
"profile": "release with LTO",
9+
"network_namespace": "unshare --user --map-root-user --net",
10+
"common_args": [
11+
"--no-config",
12+
"--config-path",
13+
"/dev/null",
14+
"--no-banner",
15+
"--scripts",
16+
"none",
17+
"--greppable",
18+
"--ports",
19+
"80",
20+
"--exclude-ports",
21+
"80"
22+
],
23+
"workloads": {
24+
"literal-ipv4": [
25+
"-a",
26+
"127.0.0.1"
27+
],
28+
"mixed-cidrs": [
29+
"-a",
30+
"127.0.0.1,192.0.2.0/30,2001:db8::/126",
31+
"--exclude-addresses",
32+
"192.0.2.1,2001:db8::1"
33+
]
34+
},
35+
"warmups_each": 2,
36+
"samples_each": 20,
37+
"binary_cpus": "0-3",
38+
"harness_cpus": "8-9",
39+
"binary_sha256": {
40+
"base": "4d6b090218ff36249ed95f14ec1a82626e8ea33854f3823f19a6de247b1524b8",
41+
"dns": "a9d6bf949f56c53d18cc85bc9b53b21bd85a63bcdd04ad78992d392c675c77ac"
42+
}
43+
},
44+
"summary": {
45+
"literal-ipv4": {
46+
"base_ms": 168.23068849999999,
47+
"dns_ms": 2.248762,
48+
"samples_each": 20
49+
},
50+
"mixed-cidrs": {
51+
"base_ms": 178.0855925,
52+
"dns_ms": 2.4539600000000004,
53+
"samples_each": 20
54+
}
55+
},
56+
"samples": {
57+
"literal-ipv4": {
58+
"base": [
59+
143.391226,
60+
146.562804,
61+
149.615525,
62+
151.188597,
63+
152.269193,
64+
159.499601,
65+
164.926958,
66+
164.347529,
67+
167.208931,
68+
165.433184,
69+
172.246939,
70+
169.252446,
71+
172.777899,
72+
170.729249,
73+
170.092681,
74+
171.863255,
75+
169.612842,
76+
170.857396,
77+
173.117403,
78+
175.206345
79+
],
80+
"dns": [
81+
2.241793,
82+
1.817341,
83+
2.39956,
84+
2.048986,
85+
3.123401,
86+
1.820017,
87+
3.615094,
88+
2.165312,
89+
2.675535,
90+
2.103261,
91+
2.531392,
92+
2.215141,
93+
2.572939,
94+
2.063803,
95+
2.671431,
96+
2.059846,
97+
2.598858,
98+
2.230916,
99+
2.678259,
100+
2.255731
101+
]
102+
},
103+
"mixed-cidrs": {
104+
"base": [
105+
175.504767,
106+
178.797842,
107+
186.183728,
108+
176.885958,
109+
177.989816,
110+
180.028568,
111+
177.66299,
112+
174.757221,
113+
175.468485,
114+
175.328023,
115+
177.639639,
116+
175.637593,
117+
177.922435,
118+
185.394396,
119+
182.587204,
120+
180.58311,
121+
178.961137,
122+
178.293186,
123+
180.482419,
124+
178.181369
125+
],
126+
"dns": [
127+
3.275193,
128+
2.114074,
129+
2.700771,
130+
2.183317,
131+
2.606538,
132+
2.368046,
133+
2.599702,
134+
2.238105,
135+
2.683892,
136+
2.292249,
137+
2.528366,
138+
2.114478,
139+
2.668921,
140+
2.103271,
141+
3.275514,
142+
2.27974,
143+
3.103311,
144+
2.265298,
145+
2.580743,
146+
2.379554
147+
]
148+
}
149+
},
150+
"invocations": 88
151+
}

‎docs/lazy-dns-benchmark.md‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Lazy DNS initialization benchmark
2+
3+
Compared current Tokio master `67c08a33100362c54b56fde784b0fd234f70fa11` with the lazy-resolver implementation
4+
at `a05592a8a88b77ad85d961137943dfadfe98b612` on 2026-10-02.
5+
6+
## Method
7+
8+
Both binaries are release builds with LTO, using Rust 1.98.1 on an Intel Core
9+
Ultra 7 165U running Linux 7.2.6-1-cachyos. Each workload used twenty measured
10+
runs and two warmups per binary, alternating A/B and B/A order. Binaries used
11+
CPUs 0-3; the harness used CPUs 8-9. No builds ran during measurement.
12+
13+
The harness ran in a private network namespace. Every invocation excluded
14+
its only requested port, so no scan traffic was sent. Configuration and
15+
scripts were disabled. Timing spans process launch through exit, including
16+
the same `taskset` affinity launcher for both builds.
17+
18+
Common arguments:
19+
20+
```sh
21+
--no-config --config-path /dev/null --no-banner --scripts none --greppable \
22+
--ports 80 --exclude-ports 80
23+
```
24+
25+
The literal case adds `-a 127.0.0.1`. The mixed case adds
26+
`-a 127.0.0.1,192.0.2.0/30,2001:db8::/126` and
27+
`--exclude-addresses 192.0.2.1,2001:db8::1`. Both use `RUST_LOG=rustscan=info`.
28+
29+
## Results
30+
31+
| Workload | Master median | Lazy DNS median | Time saved |
32+
| --- | ---: | ---: | ---: |
33+
| Literal IPv4 | 168.23 ms | 2.25 ms | 165.98 ms (98.66%) |
34+
| IPv4/IPv6 CIDRs and exclusions | 178.09 ms | 2.45 ms | 175.63 ms (98.62%) |
35+
36+
All 88 invocations exited successfully with the expected empty scan output.
37+
A separate syscall trace of the optimized literal-IP invocation recorded no
38+
reads of `/etc/hosts` or `/etc/resolv.conf` and no network connects or sends.
39+
40+
This host has a 2,760,051-byte hosts file. Eager Hickory initialization parses
41+
that file even for numeric targets. Systems with smaller hosts files will
42+
save less; this measures CLI startup, not socket throughput. Hostnames that
43+
need fallback DNS still pay resolver initialization once, and parsing order,
44+
file handling, deduplication and exclusions keep their existing behavior.
45+
46+
[Raw samples, setup and binary hashes](lazy-dns-benchmark-results.json) are
47+
included. Network-free regression tests verify that literal IPv4/IPv6 targets,
48+
CIDRs, exclusions, duplicates and empty inputs do not construct the resolver.

‎src/address.rs‎

Lines changed: 82 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
//! Provides functions to parse input IP addresses, CIDRs or files.
2+
use std::cell::LazyCell;
23
use std::collections::BTreeSet;
34
use std::fs::{self, File};
45
use std::io::{prelude::*, BufReader};
@@ -39,12 +40,23 @@ use crate::warning;
3940
/// start your runtime (as the `rustscan` binary does) or from
4041
/// `tokio::task::spawn_blocking`, not directly from async code.
4142
pub fn parse_addresses(input: &Opts) -> Vec<IpAddr> {
43+
parse_addresses_with_resolver(input, || get_resolver(&input.resolver))
44+
}
45+
46+
fn parse_addresses_with_resolver(
47+
input: &Opts,
48+
create_resolver: impl FnOnce() -> Resolver,
49+
) -> Vec<IpAddr> {
4250
let mut ips: Vec<IpAddr> = Vec::new();
4351
let mut unresolved_addresses: Vec<&str> = Vec::new();
44-
let backup_resolver = get_resolver(&input.resolver);
52+
// Most scans use literal IPs or CIDRs. Defer the resolver's runtime and
53+
// hosts-file loading until a DNS fallback actually needs them, then reuse
54+
// that resolver for the rest of the targets and exclusions.
55+
let backup_resolver = LazyCell::new(create_resolver);
56+
let resolver = || &*backup_resolver;
4557

4658
for address in &input.addresses {
47-
let parsed_ips = parse_address(address, &backup_resolver);
59+
let parsed_ips = parse_address_with_resolver(address, &resolver);
4860
if !parsed_ips.is_empty() {
4961
ips.extend(parsed_ips);
5062
} else {
@@ -66,7 +78,7 @@ pub fn parse_addresses(input: &Opts) -> Vec<IpAddr> {
6678
continue;
6779
}
6880

69-
if let Ok(x) = read_ips_from_file(file_path, &backup_resolver) {
81+
if let Ok(x) = read_ips_from_file(file_path, &resolver) {
7082
ips.extend(x);
7183
} else {
7284
warning!(
@@ -77,7 +89,7 @@ pub fn parse_addresses(input: &Opts) -> Vec<IpAddr> {
7789
}
7890
}
7991

80-
let excluded_cidrs = parse_excluded_networks(&input.exclude_addresses, &backup_resolver);
92+
let excluded_cidrs = parse_excluded_networks_with_resolver(&input.exclude_addresses, &resolver);
8193

8294
// Remove duplicated/excluded IPs.
8395
let mut seen = BTreeSet::new();
@@ -100,6 +112,13 @@ pub fn parse_addresses(input: &Opts) -> Vec<IpAddr> {
100112
/// let ips = parse_address("127.0.0.1", &Resolver::default().unwrap());
101113
/// ```
102114
pub fn parse_address(address: &str, resolver: &Resolver) -> Vec<IpAddr> {
115+
parse_address_with_resolver(address, &|| resolver)
116+
}
117+
118+
fn parse_address_with_resolver<'a>(
119+
address: &str,
120+
resolver: &impl Fn() -> &'a Resolver,
121+
) -> Vec<IpAddr> {
103122
if let Ok(addr) = IpAddr::from_str(address) {
104123
// `address` is an IP string
105124
vec![addr]
@@ -118,14 +137,17 @@ pub fn parse_address(address: &str, resolver: &Resolver) -> Vec<IpAddr> {
118137
}
119138

120139
/// Uses DNS to get the IPS associated with host
121-
fn resolve_ips_from_host(source: &str, backup_resolver: &Resolver) -> Vec<IpAddr> {
140+
fn resolve_ips_from_host<'a>(
141+
source: &str,
142+
backup_resolver: &impl Fn() -> &'a Resolver,
143+
) -> Vec<IpAddr> {
122144
let mut ips: Vec<IpAddr> = Vec::new();
123145

124146
if let Ok(addrs) = source.to_socket_addrs() {
125147
for ip in addrs {
126148
ips.push(ip.ip());
127149
}
128-
} else if let Ok(addrs) = backup_resolver.lookup_ip(source) {
150+
} else if let Ok(addrs) = backup_resolver().lookup_ip(source) {
129151
ips.extend(addrs.iter());
130152
}
131153

@@ -148,6 +170,13 @@ fn resolve_ips_from_host(source: &str, backup_resolver: &Resolver) -> Vec<IpAddr
148170
pub fn parse_excluded_networks(
149171
exclude_addresses: &Option<Vec<String>>,
150172
resolver: &Resolver,
173+
) -> Vec<IpCidr> {
174+
parse_excluded_networks_with_resolver(exclude_addresses, &|| resolver)
175+
}
176+
177+
fn parse_excluded_networks_with_resolver<'a>(
178+
exclude_addresses: &Option<Vec<String>>,
179+
resolver: &impl Fn() -> &'a Resolver,
151180
) -> Vec<IpCidr> {
152181
exclude_addresses
153182
.iter()
@@ -157,7 +186,10 @@ pub fn parse_excluded_networks(
157186
}
158187

159188
/// Parses a single address into an IpCidr, handling CIDR notation, IP addresses, and hostnames.
160-
fn parse_single_excluded_address(addr: &str, resolver: &Resolver) -> Vec<IpCidr> {
189+
fn parse_single_excluded_address<'a>(
190+
addr: &str,
191+
resolver: &impl Fn() -> &'a Resolver,
192+
) -> Vec<IpCidr> {
161193
if let Ok(cidr) = IpCidr::from_str(addr) {
162194
return vec![cidr];
163195
}
@@ -261,9 +293,9 @@ fn read_resolver_from_file(path: &str) -> Result<Vec<IpAddr>, std::io::Error> {
261293

262294
#[cfg(not(tarpaulin_include))]
263295
/// Parses an input file of IPs and uses those
264-
fn read_ips_from_file(
296+
fn read_ips_from_file<'a>(
265297
ips: &std::path::Path,
266-
backup_resolver: &Resolver,
298+
backup_resolver: &impl Fn() -> &'a Resolver,
267299
) -> Result<Vec<IpAddr>, std::io::Error> {
268300
let file = File::open(ips)?;
269301
let reader = BufReader::new(file);
@@ -272,7 +304,7 @@ fn read_ips_from_file(
272304

273305
for address_line in reader.lines() {
274306
if let Ok(address) = address_line {
275-
ips.extend(parse_address(&address, backup_resolver));
307+
ips.extend(parse_address_with_resolver(&address, backup_resolver));
276308
} else {
277309
debug!("Line in file is not valid");
278310
}
@@ -283,9 +315,48 @@ fn read_ips_from_file(
283315

284316
#[cfg(test)]
285317
mod tests {
286-
use super::{parse_addresses, Opts};
318+
use super::{parse_addresses, parse_addresses_with_resolver, Opts};
287319
use std::net::{IpAddr, Ipv4Addr};
288320

321+
#[test]
322+
fn literal_targets_and_exclusions_do_not_initialize_a_resolver() {
323+
let opts = Opts {
324+
addresses: vec![
325+
"192.0.2.0/30".to_owned(),
326+
"192.0.2.2".to_owned(),
327+
"2001:db8::/126".to_owned(),
328+
"2001:db8::3".to_owned(),
329+
],
330+
exclude_addresses: Some(vec![
331+
"192.0.2.0/31".to_owned(),
332+
"192.0.2.3".to_owned(),
333+
"2001:db8::/127".to_owned(),
334+
"2001:db8::3".to_owned(),
335+
]),
336+
..Default::default()
337+
};
338+
339+
let ips = parse_addresses_with_resolver(&opts, || {
340+
panic!("literal targets and exclusions must not initialize DNS")
341+
});
342+
343+
assert_eq!(
344+
ips,
345+
[
346+
"192.0.2.2".parse::<IpAddr>().unwrap(),
347+
"2001:db8::2".parse::<IpAddr>().unwrap(),
348+
]
349+
);
350+
}
351+
352+
#[test]
353+
fn empty_targets_do_not_initialize_a_resolver() {
354+
assert!(parse_addresses_with_resolver(&Opts::default(), || {
355+
panic!("empty targets must not initialize DNS")
356+
})
357+
.is_empty());
358+
}
359+
289360
#[test]
290361
fn parse_correct_addresses() {
291362
let opts = Opts {

0 commit comments

Comments
 (0)