Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,11 @@ it onto your `PATH`, drop the `./` prefix; on Windows use `.\dnsbench.exe`.
# Benchmark two specific servers, show the detailed table and export reports
./dnsbench run --only 1.1.1.1,9.9.9.9 --details --export json,txt --out reports

# Benchmark one-off encrypted endpoints without saving them to the user list
./dnsbench run \
--server https://dns.example/dns-query@192.0.2.1 \
--server quic://dns.example@192.0.2.1

# Benchmark and open a shareable HTML report in the browser when the run finishes
./dnsbench run --builtin --open

Expand Down Expand Up @@ -145,8 +150,29 @@ dnsbench speaks six transports: plaintext `udp` and `tcp` on port 53, `dot` (DNS

# Compare just the QUIC-based transports
./dnsbench run --protocols doh3,doq

# Compare those built-in transports with the DNS currently used by the system
./dnsbench run --system --builtin --protocols doh3,doq
```

`--protocols` normally filters every selected resolver, which excludes the
system DNS because operating systems expose it to dnsbench as UDP/53. Explicitly
selecting `--system` keeps the discovered system resolver(s) in the run
regardless of that protocol filter. Because source flags define which sources
participate, add `--builtin`, `--user` or one-off `--server` endpoints for the
encrypted side of the comparison. `--no-ipv6` and `--only` still apply.

Use repeatable `--server` flags for endpoints that should participate in only
the current run. The syntax is the same as a text server list: a bare IP address
for UDP, `tls://hostname@address` for DoT, an HTTPS URL for DoH,
`h3://hostname/path@address` for DoH/3, or `quic://hostname@address` for DoQ.
An optional `:port` can follow the address. When at least one `--server` is
provided without `--system`, `--builtin` or `--user`, the automatic default that
includes all three sources is disabled, so only the one-off endpoints (and any
explicit `--servers-file`) enter the run. Pass a source flag to combine that
source with the one-off endpoints. These entries are never written to the user
configuration.

This matters if your router intercepts port 53: every plaintext query is then answered by the router rather than by the resolver you meant to measure, and the ranking compares your router against itself.

Selecting encrypted transports does not change how a hostname-only custom endpoint is bootstrapped: the system resolver still has to resolve that hostname before the encrypted connection exists. Every encrypted entry in the built-in list avoids that lookup by pinning the resolver's IP address in the `address` field and dialing it directly, while keeping the hostname for SNI and certificate validation. Add an `address` to a custom entry when you want the same behavior.
Expand All @@ -171,7 +197,7 @@ For a full, shareable report use `--export html`, or `--open`, which writes the

## Measurement pacing

During triage and benchmarking, dnsbench spaces query launches globally (`--pace`, default 20ms) and reuses one connected socket per server, so a run does not flood the local Wi-Fi link, NAT router or uplink and then misread the resulting drops as server loss. `--pace` is where the benchmark starts, not a bound: it shortens on clean networks to as little as a quarter of the configured value and, when timeouts appear across several servers at once (the signature of local congestion rather than of any one server), eases off to as much as 8× it and notes the adjustment in the live view. The preceding characterization phase has no launch pacer; its small qualitative workload reuses one persistent session per server and is bounded by `--concurrency`. See [docs/METHODOLOGY.md](docs/METHODOLOGY.md) for details.
During triage and benchmarking, dnsbench spaces query launches globally and reuses one connected socket per server, so a run does not flood the local Wi-Fi link, NAT router or uplink and then misread the resulting drops as server loss. When `--pace` is omitted, its 20ms default is adaptive but never becomes faster. A burst of launch-correlated timeouts across at least three independent, recently healthy resolver groups signals **possible shared-path congestion** and moves the interval from 20ms to 40ms and then at most 80ms; after the cooldown, clean traffic recovers gradually to 20ms. Endpoints from the same known operator count as one group, as do different protocols on the same address when no operator metadata exists. Passing `--pace DURATION` makes the interval fixed and disables both adaptation and pacing jitter. `--concurrency` remains a separate hard limit on simultaneous attempts and does not set QPS. Adjustment evidence is retained in JSON exports. See [docs/METHODOLOGY.md](docs/METHODOLOGY.md) for details.

While a run is in progress dnsbench also asks the operating system not to enter idle sleep, so leaving the machine unattended (or letting the display turn off) does not suspend the process mid-run and poison the timings. It uses the native mechanism of each platform — `caffeinate` on macOS, a systemd-logind idle inhibitor lock held over D-Bus on Linux, and `SetThreadExecutionState` on Windows.

Expand Down
73 changes: 66 additions & 7 deletions cmd/dnsbench/probe.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,18 @@ type selectionFlags struct {
system bool
builtin bool
user bool
servers []string
only []string
serversFile string
noIPv6 bool
protocols []string
}

func registerSelectionFlags(cmd *cobra.Command, sel *selectionFlags) {
cmd.Flags().BoolVar(&sel.system, "system", false, "include the system-configured DNS resolvers")
cmd.Flags().BoolVar(&sel.system, "system", false, "include the system-configured DNS resolvers, even when excluded by --protocols")
cmd.Flags().BoolVar(&sel.builtin, "builtin", false, "include the built-in public DNS resolvers")
cmd.Flags().BoolVar(&sel.user, "user", false, "include the user resolver list")
cmd.Flags().StringArrayVar(&sel.servers, "server", nil, "one-off resolver endpoint (IP, tls://, https://, h3:// or quic://); repeat to add more")
cmd.Flags().StringSliceVar(&sel.only, "only", nil, "restrict to resolvers matching these IDs or addresses")
cmd.Flags().StringVar(&sel.serversFile, "servers-file", "", "extra resolver list file to include (.json, .csv or .txt)")
cmd.Flags().BoolVar(&sel.noIPv6, "no-ipv6", false, "exclude resolvers with IPv6 addresses")
Expand All @@ -50,13 +52,28 @@ type selectedServers struct {
}

func selectServers(ctx context.Context, sel selectionFlags) (selectedServers, error) {
return selectServersWithDiscovery(ctx, sel, sysdns.Discover)
}

func selectServersWithDiscovery(
ctx context.Context,
sel selectionFlags,
discover func(context.Context) ([]model.Server, []string, []string, error),
) (selectedServers, error) {
var out selectedServers
includeSystem, includeBuiltin, includeUser := sel.system, sel.builtin, sel.user
if !includeSystem && !includeBuiltin && !includeUser {
if !includeSystem && !includeBuiltin && !includeUser && len(sel.servers) == 0 {
includeSystem, includeBuiltin, includeUser = true, true, true
}
var lists [][]model.Server
system, ifaces, warnings, discoverErr := sysdns.Discover(ctx)
if len(sel.servers) > 0 {
inline, err := parseInlineServers(sel.servers)
if err != nil {
return out, err
}
lists = append(lists, inline)
}
system, ifaces, warnings, discoverErr := discover(ctx)
if discoverErr != nil {
out.warnings = append(out.warnings, "system DNS discovery failed: "+discoverErr.Error())
} else {
Expand Down Expand Up @@ -89,10 +106,7 @@ func selectServers(ctx context.Context, sel selectionFlags) (selectedServers, er
if err != nil {
return out, err
}
servers = serverlist.Filter(servers, serverlist.FilterOptions{
Protocols: protos,
IPv4Only: sel.noIPv6,
})
servers = filterSelectedServers(servers, out.systemServers, protos, sel.noIPv6, sel.system)
if len(sel.only) > 0 {
servers = filterOnly(servers, sel.only)
}
Expand All @@ -101,6 +115,51 @@ func selectServers(ctx context.Context, sel selectionFlags) (selectedServers, er
return out, nil
}

func filterSelectedServers(servers, system []model.Server, protocols []model.Protocol, ipv4Only, keepSystem bool) []model.Server {
systemKeys := make(map[string]bool, len(system))
if keepSystem {
for _, server := range system {
systemKeys[server.Key()] = true
}
}
out := make([]model.Server, 0, len(servers))
for _, server := range servers {
if ipv4Only && server.IsIPv6() {
continue
}
protocolMatches := len(protocols) == 0 || slices.Contains(protocols, server.Protocol)
if !protocolMatches && !systemKeys[server.Key()] {
continue
}
out = append(out, server)
}
return out
}

func parseInlineServers(entries []string) ([]model.Server, error) {
servers := make([]model.Server, 0, len(entries))
for _, raw := range entries {
entry := strings.TrimSpace(raw)
if entry == "" {
return nil, usageErrorf("--server requires a non-empty endpoint")
}
decoded, err := serverlist.DecodeText([]byte(entry))
if err != nil {
return nil, usageErrorf("invalid --server value %q: %v", raw, err)
}
if len(decoded) != 1 {
return nil, usageErrorf("--server value %q must contain exactly one endpoint", raw)
}
server := decoded[0]
server.Source = model.SourceUser
if err := serverlist.ValidateAndPrepare(&server); err != nil {
return nil, usageErrorf("invalid --server value %q: %v", raw, err)
}
servers = append(servers, server)
}
return servers, nil
}

func matchSystemServerIDs(selected, system []model.Server) []string {
systemKeys := make(map[string]bool, len(system))
for _, server := range system {
Expand Down
8 changes: 6 additions & 2 deletions cmd/dnsbench/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ type runFlags struct {
retryInterval time.Duration
concurrency int
pace time.Duration
paceExplicit bool
gap time.Duration
session string
seed int64
Expand Down Expand Up @@ -74,6 +75,7 @@ time of day.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
f.rounds = mustInt(cmd, "rounds")
f.paceExplicit = cmd.Flags().Changed("pace")
return executeRun(cmd, &f)
},
}
Expand All @@ -90,8 +92,8 @@ time of day.`,
cmd.Flags().DurationVar(&f.timeout, "timeout", base.Timeout, "timeout per query")
cmd.Flags().IntVar(&f.retries, "retries", base.Retries, "retries per failed query")
cmd.Flags().DurationVar(&f.retryInterval, "retry-interval", base.RetryInterval, "wait between retries")
cmd.Flags().IntVar(&f.concurrency, "concurrency", base.Concurrency, "maximum queries in flight at once")
cmd.Flags().DurationVar(&f.pace, "pace", base.PaceInterval, "starting spacing between any two query launches, across all resolvers; adapts between a quarter of it and 8x it (0 disables pacing)")
cmd.Flags().IntVar(&f.concurrency, "concurrency", base.Concurrency, "hard limit on simultaneous DNS queries; does not control QPS")
cmd.Flags().DurationVar(&f.pace, "pace", base.PaceInterval, "fixed minimum interval between query starts across all resolvers; when omitted, the 20ms default adapts up to 4x on possible shared-path congestion (0 disables pacing)")
cmd.Flags().DurationVar(&f.gap, "gap", base.PerServerGap, "gap between consecutive queries to the same resolver")
cmd.Flags().StringVar(&f.session, "session", string(base.Session), "connection reuse: cold or persistent")
cmd.Flags().Int64Var(&f.seed, "seed", 0, "random seed (0 picks a random seed)")
Expand Down Expand Up @@ -240,6 +242,7 @@ func executeRun(cmd *cobra.Command, f *runFlags) error {
Stats: statsMap,
Scores: scores,
Samples: samples,
PaceAdjustments: engine.PaceAdjustments(),
}
res.Comparisons = buildComparisons(res, rankingMode)
exportPaths, exportErr := writeExportFiles(res, f, rankingMode)
Expand Down Expand Up @@ -389,6 +392,7 @@ func buildBenchConfig(f *runFlags, mode model.Mode, session model.SessionMode) (
cfg.RetryInterval = f.retryInterval
cfg.Concurrency = f.concurrency
cfg.PaceInterval = f.pace
cfg.PaceAdaptive = !f.paceExplicit && f.pace > 0
cfg.PerServerGap = f.gap
cfg.Session = session
cfg.Seed = f.seed
Expand Down
25 changes: 25 additions & 0 deletions cmd/dnsbench/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,31 @@ func TestRunCommandModernDefaults(t *testing.T) {
if got := cmd.Flags().Lookup("triage-threshold").DefValue; got != "200ms" {
t.Fatalf("--triage-threshold default = %q, want 200ms", got)
}
if got := cmd.Flags().Lookup("concurrency").Usage; !strings.Contains(got, "hard limit") || !strings.Contains(got, "does not control QPS") {
t.Fatalf("--concurrency help does not separate in-flight queries from QPS: %q", got)
}
if got := cmd.Flags().Lookup("pace").Usage; !strings.Contains(got, "fixed minimum interval") || !strings.Contains(got, "when omitted") {
t.Fatalf("--pace help does not distinguish fixed and adaptive behavior: %q", got)
}
}

func TestBuildBenchConfigMakesExplicitPaceFixed(t *testing.T) {
base := model.DefaultBenchConfig(model.ModeStandard)
flags := runFlags{
pace: 30 * time.Millisecond,
paceExplicit: true,
}
cfg, _ := buildBenchConfig(&flags, model.ModeStandard, model.SessionPersistent)
if cfg.PaceInterval != 30*time.Millisecond || cfg.PaceAdaptive {
t.Fatalf("explicit pacing = %s adaptive=%t, want fixed 30ms", cfg.PaceInterval, cfg.PaceAdaptive)
}

flags.pace = base.PaceInterval
flags.paceExplicit = false
cfg, _ = buildBenchConfig(&flags, model.ModeStandard, model.SessionPersistent)
if cfg.PaceInterval != base.PaceInterval || !cfg.PaceAdaptive {
t.Fatalf("default pacing = %s adaptive=%t, want adaptive %s", cfg.PaceInterval, cfg.PaceAdaptive, base.PaceInterval)
}
}

func TestProbeCommandHasNoPacingFlag(t *testing.T) {
Expand Down
Loading
Loading