Script 'mail_helper' called by obssrc
Hello community,

here is the log from the commit of package doggo for openSUSE:Factory checked 
in at 2026-05-28 23:12:18
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/doggo (Old)
 and      /work/SRC/openSUSE:Factory/.doggo.new.1937 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "doggo"

Thu May 28 23:12:18 2026 rev:10 rq:1355657 version:1.1.7

Changes:
--------
--- /work/SRC/openSUSE:Factory/doggo/doggo.changes      2026-05-20 
15:27:15.560908531 +0200
+++ /work/SRC/openSUSE:Factory/.doggo.new.1937/doggo.changes    2026-05-28 
23:12:46.857476497 +0200
@@ -1,0 +2,22 @@
+Thu May 28 07:28:02 UTC 2026 - Johannes Kastl 
<[email protected]>
+
+- Update to version 1.1.7:
+  * New Features
+    - be67297: feat(app): debug-log nameserver strategy application
+      (@mr-karan)
+    - 622bd7f: feat(resolvers): tag lookup errors with nameserver
+      and support partial success (@mr-karan)
+  * Bug fixes
+    - af1fa7f: fix(resolvers): apply strategy to nameserver
+      overrides (#238) (@mr-karan)
+    - dd88d6e: fix(resolvers): avoid invalid root search queries
+      (#239) (@mr-karan)
+    - da5d86a: fix(resolvers): preserve completed work when context
+      expires mid-flight (@mr-karan)
+    - b400c46: fix(web): preserve partial resolver results in API
+      handler (@mr-karan)
+  * Others
+    - dea30ad: chore: bump Go to 1.26.3 for stdlib security fixes
+      (@mr-karan)
+
+-------------------------------------------------------------------

Old:
----
  doggo-1.1.6.obscpio

New:
----
  doggo-1.1.7.obscpio

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ doggo.spec ++++++
--- /var/tmp/diff_new_pack.nk3Dgi/_old  2026-05-28 23:12:51.057649309 +0200
+++ /var/tmp/diff_new_pack.nk3Dgi/_new  2026-05-28 23:12:51.061649474 +0200
@@ -17,7 +17,7 @@
 
 
 Name:           doggo
-Version:        1.1.6
+Version:        1.1.7
 Release:        0
 Summary:        CLI tool and API server DNS client implemented in Go
 License:        GPL-3.0-only
@@ -25,7 +25,7 @@
 URL:            https://github.com/mr-karan/doggo
 Source0:        %{name}-%{version}.tar
 Source1:        vendor.tar.xz
-BuildRequires:  go1.26 >= 1.26.2
+BuildRequires:  go1.26 >= 1.26.3
 Recommends:     %{name}-bash-completion
 Suggests:       %{name}-fish-completion
 Suggests:       %{name}-zsh-completion

++++++ _service ++++++
--- /var/tmp/diff_new_pack.nk3Dgi/_old  2026-05-28 23:12:51.109651449 +0200
+++ /var/tmp/diff_new_pack.nk3Dgi/_new  2026-05-28 23:12:51.113651613 +0200
@@ -2,7 +2,7 @@
   <service name="obs_scm" mode="manual">
     <param name="scm">git</param>
     <param name="url">https://github.com/mr-karan/doggo.git</param>
-    <param name="revision">v1.1.6</param>
+    <param name="revision">v1.1.7</param>
     <param name="match-tag">*</param>
     <param name="versionrewrite-pattern">v(\d+\.\d+\.\d+)</param>
     <param name="versionformat">@PARENT_TAG@</param>

++++++ _servicedata ++++++
--- /var/tmp/diff_new_pack.nk3Dgi/_old  2026-05-28 23:12:51.137652601 +0200
+++ /var/tmp/diff_new_pack.nk3Dgi/_new  2026-05-28 23:12:51.145652930 +0200
@@ -1,6 +1,6 @@
 <servicedata>
 <service name="tar_scm">
                 <param name="url">https://github.com/mr-karan/doggo.git</param>
-              <param 
name="changesrevision">2bcf2f719d2017db2d525cd8b31c65f4b8b54e69</param></service></servicedata>
+              <param 
name="changesrevision">b400c464b445c79805a9987475f862357ce7f49d</param></service></servicedata>
 (No newline at EOF)
 

++++++ doggo-1.1.6.obscpio -> doggo-1.1.7.obscpio ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/doggo-1.1.6/cmd/doggo/cli.go 
new/doggo-1.1.7/cmd/doggo/cli.go
--- old/doggo-1.1.6/cmd/doggo/cli.go    2026-05-20 09:36:30.000000000 +0200
+++ new/doggo-1.1.7/cmd/doggo/cli.go    2026-05-22 15:05:55.000000000 +0200
@@ -3,6 +3,7 @@
 import (
        "context"
        "encoding/json"
+       "errors"
        "fmt"
        "log/slog"
        "math"
@@ -19,6 +20,15 @@
        flag "github.com/spf13/pflag"
 )
 
+// Exit codes used by the CLI. Exit 0 is implicit success; partial success
+// (some resolvers answered, others failed) is 2; full lookup failure remains
+// 9 to preserve compatibility with the pre-existing convention.
+const (
+       exitGenericFailure = 1
+       exitPartialFailure = 2
+       exitLookupFailure  = 9
+)
+
 var (
        buildVersion = "unknown"
        buildDate    = "unknown"
@@ -89,8 +99,8 @@
                os.Exit(0)
        }
 
-       responses, errors := performLookup(app, cfg)
-       outputResults(app, responses, errors)
+       responses, lookupErrors := performLookup(app, cfg)
+       outputResults(app, responses, lookupErrors)
 }
 
 type config struct {
@@ -283,12 +293,16 @@
                        defer wg.Done()
                        responses, err := r.Lookup(ctx, app.Questions, 
cfg.queryFlags)
                        mu.Lock()
+                       defer mu.Unlock()
+                       // Collect any responses the resolver produced even 
when err != nil
+                       // so partial successes within a single resolver still 
surface.
+                       allResponses = append(allResponses, responses...)
                        if err != nil {
-                               allErrors = append(allErrors, err)
-                       } else {
-                               allResponses = append(allResponses, 
responses...)
+                               allErrors = append(allErrors, 
&resolvers.LookupError{
+                                       Nameserver: r.Address(),
+                                       Err:        err,
+                               })
                        }
-                       mu.Unlock()
                }(resolver)
        }
 
@@ -300,30 +314,83 @@
        if app.QueryFlags.ShowJSON {
                outputJSON(app.Logger, responses, responseErrors)
        } else {
-               if len(responseErrors) > 0 {
-                       app.Logger.Error("Error looking up DNS records", 
"error", responseErrors[0])
-                       os.Exit(9)
+               // Full failure: no resolver produced a usable response. 
Surface every
+               // per-resolver error so the user can see which nameservers 
failed and
+               // why, then exit with the legacy lookup-failure code.
+               if len(responses) == 0 && len(responseErrors) > 0 {
+                       for _, err := range responseErrors {
+                               logResolverError(app.Logger, slog.LevelError, 
"Error looking up DNS records", err)
+                       }
+                       os.Exit(exitLookupFailure)
+               }
+               // Partial success: at least one resolver answered while another
+               // failed. Demote the failure to a warning and print whatever 
we have.
+               for _, err := range responseErrors {
+                       logResolverError(app.Logger, slog.LevelWarn, "lookup 
failed", err)
                }
                app.Output(responses)
        }
+
+       if len(responseErrors) > 0 && len(responses) > 0 {
+               os.Exit(exitPartialFailure)
+       }
+       if len(responseErrors) > 0 {
+               os.Exit(exitLookupFailure)
+       }
+}
+
+// logResolverError emits a per-resolver lookup error at the given level,
+// unwrapping LookupError so the nameserver shows up as its own structured
+// field rather than embedded in the message.
+func logResolverError(logger *slog.Logger, level slog.Level, msg string, err 
error) {
+       var lookupErr *resolvers.LookupError
+       if errors.As(err, &lookupErr) {
+               logger.Log(context.Background(), level, msg,
+                       "nameserver", lookupErr.Nameserver,
+                       "error", lookupErr.Err,
+               )
+               return
+       }
+       logger.Log(context.Background(), level, msg, "error", err)
+}
+
+// resolverErrorJSON is the per-resolver error shape returned in JSON output.
+type resolverErrorJSON struct {
+       Nameserver string `json:"nameserver,omitempty"`
+       Error      string `json:"error"`
 }
 
 func outputJSON(logger *slog.Logger, responses []resolvers.Response, 
responseErrors []error) {
        jsonOutput := struct {
                Responses []resolvers.Response `json:"responses,omitempty"`
-               Error     string               `json:"error,omitempty"`
+               Errors    []resolverErrorJSON  `json:"errors,omitempty"`
+               // Error is kept for backwards compatibility with scripts that 
parsed
+               // the previous schema. It is populated only on full failure.
+               Error string `json:"error,omitempty"`
        }{
                Responses: responses,
        }
 
-       if len(responseErrors) > 0 {
+       for _, err := range responseErrors {
+               var lookupErr *resolvers.LookupError
+               if errors.As(err, &lookupErr) {
+                       jsonOutput.Errors = append(jsonOutput.Errors, 
resolverErrorJSON{
+                               Nameserver: lookupErr.Nameserver,
+                               Error:      lookupErr.Err.Error(),
+                       })
+                       continue
+               }
+               jsonOutput.Errors = append(jsonOutput.Errors, 
resolverErrorJSON{Error: err.Error()})
+       }
+
+       if len(responses) == 0 && len(responseErrors) > 0 {
                jsonOutput.Error = responseErrors[0].Error()
        }
 
        jsonData, err := json.MarshalIndent(jsonOutput, "", "  ")
        if err != nil {
                logger.Error("Error marshaling JSON")
-               os.Exit(1)
+               os.Exit(exitGenericFailure)
        }
        fmt.Println(string(jsonData))
 }
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/doggo-1.1.6/cmd/doggo/completions.go 
new/doggo-1.1.7/cmd/doggo/completions.go
--- old/doggo-1.1.6/cmd/doggo/completions.go    2026-05-20 09:36:30.000000000 
+0200
+++ new/doggo-1.1.7/cmd/doggo/completions.go    2026-05-22 15:05:55.000000000 
+0200
@@ -65,7 +65,7 @@
     '(-c --class)'{-c,--class}'[Network class of the DNS record being 
queried]:network class:(IN CH HS)' \
     '(-r --reverse)'{-r,--reverse}'[Performs a DNS Lookup for an IPv4 or IPv6 
address]' \
     '--any[Query all supported DNS record types]' \
-    '--strategy[Strategy to query nameserver listed in 
etc/resolv.conf]:strategy:(all random first internal)' \
+    '--strategy[Strategy to query nameservers]:strategy:(all random first 
internal)' \
     '--ndots[Number of required dots in hostname to assume FQDN]:number of 
dots' \
     '--search[Use the search list defined in resolv.conf]:setting:(true 
false)' \
     '--timeout[Timeout (in seconds) for the resolver to return a 
response]:seconds' \
@@ -129,7 +129,7 @@
 complete -c doggo -n '__fish_doggo_no_subcommand' -l 'any'               -d 
"Query all supported DNS record types"
 
 # Resolver options
-complete -c doggo -n '__fish_doggo_no_subcommand' -l 'strategy'  -d "Strategy 
to query nameserver listed in etc/resolv.conf" -x -a "all random first internal"
+complete -c doggo -n '__fish_doggo_no_subcommand' -l 'strategy'  -d "Strategy 
to query nameservers" -x -a "all random first internal"
 complete -c doggo -n '__fish_doggo_no_subcommand' -l 'ndots'     -d "Specify 
ndots parameter"
 complete -c doggo -n '__fish_doggo_no_subcommand' -l 'search'    -d "Use the 
search list defined in resolv.conf" -x -a "true false"
 complete -c doggo -n '__fish_doggo_no_subcommand' -l 'timeout'   -d "Specify 
timeout (in seconds) for the resolver to return a response"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/doggo-1.1.6/cmd/doggo/help.go 
new/doggo-1.1.7/cmd/doggo/help.go
--- old/doggo-1.1.6/cmd/doggo/help.go   2026-05-20 09:36:30.000000000 +0200
+++ new/doggo-1.1.7/cmd/doggo/help.go   2026-05-22 15:05:55.000000000 +0200
@@ -121,7 +121,7 @@
                        {"--any", "Query all supported DNS record types (A, 
AAAA, CNAME, MX, NS, PTR, SOA, SRV, TXT, CAA)."},
                },
                "ResolverOptions": []Option{
-                       {"--strategy=STRATEGY", "Specify strategy to query 
nameserver listed in etc/resolv.conf. Options: all, random, first, internal 
(RFC 1918/ULA private IPs only)."},
+                       {"--strategy=STRATEGY", "Specify strategy to query 
nameservers. Options: all, random, first, internal (RFC 1918/ULA private IPs 
only)."},
                        {"--ndots=INT", "Specify ndots parameter. Takes value 
from /etc/resolv.conf if using the system namesever or 1 otherwise."},
                        {"--search", "Use the search list defined in 
resolv.conf. Defaults to true. Set --search=false to disable search list."},
                        {"--timeout=DURATION", "Specify timeout for the 
resolver to return a response (e.g., 5s, 400ms, 1m)."},
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/doggo-1.1.6/cmd/doggo/integration_test.go 
new/doggo-1.1.7/cmd/doggo/integration_test.go
--- old/doggo-1.1.6/cmd/doggo/integration_test.go       1970-01-01 
01:00:00.000000000 +0100
+++ new/doggo-1.1.7/cmd/doggo/integration_test.go       2026-05-22 
15:05:55.000000000 +0200
@@ -0,0 +1,328 @@
+package main_test
+
+import (
+       "encoding/json"
+       "errors"
+       "fmt"
+       "net"
+       "os"
+       "os/exec"
+       "path/filepath"
+       "regexp"
+       "runtime"
+       "strings"
+       "sync"
+       "testing"
+       "time"
+
+       "github.com/miekg/dns"
+)
+
+// integrationBuild lazily compiles the doggo binary for the integration suite
+// so the tests exercise the real exit codes and stdout/stderr emitted by the
+// CLI, not just the library APIs.
+var (
+       integrationBuildOnce sync.Once
+       integrationBinPath   string
+       integrationBuildErr  error
+)
+
+func doggoBin(t *testing.T) string {
+       t.Helper()
+       integrationBuildOnce.Do(func() {
+               if testing.Short() {
+                       integrationBuildErr = errors.New("skipped in -short 
mode")
+                       return
+               }
+               dir, err := os.MkdirTemp("", "doggo-bin-")
+               if err != nil {
+                       integrationBuildErr = err
+                       return
+               }
+               bin := filepath.Join(dir, "doggo")
+               if runtime.GOOS == "windows" {
+                       bin += ".exe"
+               }
+               // Build from the cmd/doggo package; integration_test.go lives 
there so
+               // using `.` would pick up tests-only deps. Use the module path
+               // explicitly to avoid that.
+               cmd := exec.Command("go", "build", "-o", bin, 
"github.com/mr-karan/doggo/cmd/doggo")
+               out, err := cmd.CombinedOutput()
+               if err != nil {
+                       integrationBuildErr = fmt.Errorf("go build failed: 
%v\n%s", err, out)
+                       return
+               }
+               integrationBinPath = bin
+       })
+       if integrationBuildErr != nil {
+               t.Skipf("doggo binary unavailable: %v", integrationBuildErr)
+       }
+       return integrationBinPath
+}
+
+// startDNSServer starts a UDP DNS test server bound to 127.0.0.1 on a random
+// port. The handler answers A queries for the supplied domain with the given
+// IP. Returns the address as "host:port" and a shutdown function the test
+// must call.
+func startDNSServer(t *testing.T, domain, answer string) (string, func()) {
+       t.Helper()
+       mux := dns.NewServeMux()
+       mux.HandleFunc(dns.Fqdn(domain), func(w dns.ResponseWriter, req 
*dns.Msg) {
+               m := new(dns.Msg)
+               m.SetReply(req)
+               m.Authoritative = true
+               for _, q := range req.Question {
+                       if q.Qtype == dns.TypeA {
+                               rr, err := dns.NewRR(fmt.Sprintf("%s 60 IN A 
%s", q.Name, answer))
+                               if err != nil {
+                                       continue
+                               }
+                               m.Answer = append(m.Answer, rr)
+                       }
+               }
+               _ = w.WriteMsg(m)
+       })
+
+       conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: 
net.ParseIP("127.0.0.1"), Port: 0})
+       if err != nil {
+               t.Fatalf("ListenUDP: %v", err)
+       }
+       srv := &dns.Server{PacketConn: conn, Handler: mux}
+       ready := make(chan struct{})
+       srv.NotifyStartedFunc = func() { close(ready) }
+
+       go func() {
+               _ = srv.ActivateAndServe()
+       }()
+
+       select {
+       case <-ready:
+       case <-time.After(2 * time.Second):
+               _ = srv.Shutdown()
+               t.Fatal("DNS test server did not start within 2s")
+       }
+
+       return conn.LocalAddr().String(), func() {
+               _ = srv.Shutdown()
+       }
+}
+
+// reservedClosedPort returns a TCP/UDP port that almost certainly has nothing
+// listening: we bind, capture the port, then close. There is a TOCTOU window
+// but it is large enough for these tests and the port lives on loopback only.
+func reservedClosedPort(t *testing.T) int {
+       t.Helper()
+       conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: 
net.ParseIP("127.0.0.1"), Port: 0})
+       if err != nil {
+               t.Fatalf("ListenUDP: %v", err)
+       }
+       port := conn.LocalAddr().(*net.UDPAddr).Port
+       _ = conn.Close()
+       return port
+}
+
+func runDoggo(t *testing.T, args ...string) (stdout, stderr string, exit int) {
+       t.Helper()
+       bin := doggoBin(t)
+       cmd := exec.Command(bin, args...)
+       cmd.Env = append(os.Environ(), "NO_COLOR=1")
+       var outBuf, errBuf strings.Builder
+       cmd.Stdout = &outBuf
+       cmd.Stderr = &errBuf
+       err := cmd.Run()
+       exit = 0
+       if err != nil {
+               var ee *exec.ExitError
+               if errors.As(err, &ee) {
+                       exit = ee.ExitCode()
+               } else {
+                       t.Fatalf("cmd.Run: %v\nstderr: %s", err, 
errBuf.String())
+               }
+       }
+       return outBuf.String(), errBuf.String(), exit
+}
+
+func TestPartialFailureExitsTwoAndPrintsResponse(t *testing.T) {
+       serverAddr, stop := startDNSServer(t, "example.test", "192.0.2.10")
+       defer stop()
+
+       deadPort := reservedClosedPort(t)
+       deadAddr := fmt.Sprintf("127.0.0.1:%d", deadPort)
+
+       stdout, stderr, exit := runDoggo(t,
+               "--timeout=2s",
+               "@"+serverAddr,
+               "@"+deadAddr,
+               "A",
+               "example.test",
+       )
+
+       if exit != 2 {
+               t.Fatalf("exit = %d, want 2 (partial 
failure)\nstdout:\n%s\nstderr:\n%s", exit, stdout, stderr)
+       }
+       if !strings.Contains(stdout, "192.0.2.10") {
+               t.Fatalf("stdout missing successful answer\nstdout:\n%s", 
stdout)
+       }
+       if !strings.Contains(stderr, "lookup failed") {
+               t.Fatalf("stderr missing per-resolver warning\nstderr:\n%s", 
stderr)
+       }
+       if !strings.Contains(stderr, deadAddr) {
+               t.Fatalf("stderr missing dead nameserver 
identity\nstderr:\n%s", stderr)
+       }
+}
+
+func TestFullFailureExitsNine(t *testing.T) {
+       deadPort := reservedClosedPort(t)
+       deadAddr := fmt.Sprintf("127.0.0.1:%d", deadPort)
+
+       stdout, stderr, exit := runDoggo(t,
+               "--timeout=2s",
+               "@"+deadAddr,
+               "A",
+               "example.test",
+       )
+
+       if exit != 9 {
+               t.Fatalf("exit = %d, want 9 (full 
failure)\nstdout:\n%s\nstderr:\n%s", exit, stdout, stderr)
+       }
+       if !strings.Contains(stderr, "Error looking up DNS records") {
+               t.Fatalf("stderr missing top-level failure 
message\nstderr:\n%s", stderr)
+       }
+       if !strings.Contains(stderr, deadAddr) {
+               t.Fatalf("stderr missing dead nameserver 
identity\nstderr:\n%s", stderr)
+       }
+}
+
+func TestCleanSuccessExitsZero(t *testing.T) {
+       serverAddr, stop := startDNSServer(t, "clean.test", "192.0.2.20")
+       defer stop()
+
+       stdout, _, exit := runDoggo(t,
+               "--timeout=2s",
+               "@"+serverAddr,
+               "A",
+               "clean.test",
+       )
+
+       if exit != 0 {
+               t.Fatalf("exit = %d, want 0", exit)
+       }
+       if !strings.Contains(stdout, "192.0.2.20") {
+               t.Fatalf("stdout missing answer\nstdout:\n%s", stdout)
+       }
+}
+
+func TestPartialFailureJSONOutputIncludesErrorsArray(t *testing.T) {
+       serverAddr, stop := startDNSServer(t, "json.test", "192.0.2.30")
+       defer stop()
+
+       deadPort := reservedClosedPort(t)
+       deadAddr := fmt.Sprintf("127.0.0.1:%d", deadPort)
+
+       stdout, stderr, exit := runDoggo(t,
+               "--timeout=2s",
+               "--json",
+               "@"+serverAddr,
+               "@"+deadAddr,
+               "A",
+               "json.test",
+       )
+
+       if exit != 2 {
+               t.Fatalf("exit = %d, want 2\nstdout:\n%s\nstderr:\n%s", exit, 
stdout, stderr)
+       }
+
+       var payload struct {
+               Responses []map[string]any `json:"responses"`
+               Errors    []struct {
+                       Nameserver string `json:"nameserver"`
+                       Error      string `json:"error"`
+               } `json:"errors"`
+               Error string `json:"error"`
+       }
+       if err := json.Unmarshal([]byte(stdout), &payload); err != nil {
+               t.Fatalf("invalid JSON: %v\nstdout:\n%s", err, stdout)
+       }
+       if len(payload.Responses) == 0 {
+               t.Fatalf("expected at least one response, got %d\nstdout:\n%s", 
len(payload.Responses), stdout)
+       }
+       if len(payload.Errors) == 0 {
+               t.Fatalf("expected populated errors[], got 0\nstdout:\n%s", 
stdout)
+       }
+       if payload.Errors[0].Nameserver != deadAddr {
+               t.Fatalf("errors[0].nameserver = %q, want %q", 
payload.Errors[0].Nameserver, deadAddr)
+       }
+       if payload.Error != "" {
+               t.Fatalf(`legacy "error" field should be empty on partial 
failure, got %q`, payload.Error)
+       }
+}
+
+func TestFullFailureJSONOutputPopulatesLegacyErrorField(t *testing.T) {
+       deadPort := reservedClosedPort(t)
+       deadAddr := fmt.Sprintf("127.0.0.1:%d", deadPort)
+
+       stdout, _, exit := runDoggo(t,
+               "--timeout=2s",
+               "--json",
+               "@"+deadAddr,
+               "A",
+               "json.test",
+       )
+
+       if exit != 9 {
+               t.Fatalf("exit = %d, want 9\nstdout:\n%s", exit, stdout)
+       }
+
+       var payload struct {
+               Errors []struct {
+                       Nameserver string `json:"nameserver"`
+                       Error      string `json:"error"`
+               } `json:"errors"`
+               Error string `json:"error"`
+       }
+       if err := json.Unmarshal([]byte(stdout), &payload); err != nil {
+               t.Fatalf("invalid JSON: %v\nstdout:\n%s", err, stdout)
+       }
+       if payload.Error == "" {
+               t.Fatalf(`legacy "error" should be populated on full 
failure\nstdout:\n%s`, stdout)
+       }
+       if len(payload.Errors) == 0 {
+               t.Fatalf("errors[] should still be populated for new 
clients\nstdout:\n%s", stdout)
+       }
+}
+
+func TestDebugLogsStrategyApplication(t *testing.T) {
+       serverAddr, stop := startDNSServer(t, "debug.test", "192.0.2.40")
+       defer stop()
+
+       deadPort := reservedClosedPort(t)
+       deadAddr := fmt.Sprintf("127.0.0.1:%d", deadPort)
+
+       _, stderr, exit := runDoggo(t,
+               "--timeout=2s",
+               "--debug",
+               "--strategy=first",
+               "@"+serverAddr,
+               "@"+deadAddr,
+               "A",
+               "debug.test",
+       )
+       if exit != 0 && exit != 2 {
+               t.Fatalf("exit = %d, want 0 or 2\nstderr:\n%s", exit, stderr)
+       }
+
+       // Debug log should describe the strategy decision so users no longer 
have
+       // to guess why their second @host was silently dropped.
+       if !strings.Contains(stderr, "Applying nameserver strategy") {
+               t.Fatalf("missing strategy-application debug log\nstderr:\n%s", 
stderr)
+       }
+       if !strings.Contains(stderr, "Applied nameserver strategy") {
+               t.Fatalf("missing strategy-applied debug log\nstderr:\n%s", 
stderr)
+       }
+       if !strings.Contains(stderr, `source=explicit`) {
+               t.Fatalf(`missing source="explicit" label\nstderr:\n%s`, stderr)
+       }
+       if !regexp.MustCompile(`dropped_count=1`).MatchString(stderr) {
+               t.Fatalf("missing dropped_count=1 indicating @deadAddr was 
filtered\nstderr:\n%s", stderr)
+       }
+}
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/doggo-1.1.6/docs/src/content/docs/resolvers/system.md 
new/doggo-1.1.7/docs/src/content/docs/resolvers/system.md
--- old/doggo-1.1.6/docs/src/content/docs/resolvers/system.md   2026-05-20 
09:36:30.000000000 +0200
+++ new/doggo-1.1.7/docs/src/content/docs/resolvers/system.md   2026-05-22 
15:05:55.000000000 +0200
@@ -42,7 +42,7 @@
 
 ## Resolver Strategy
 
-The resolver strategy determines how Doggo uses the nameservers listed in 
`/etc/resolv.conf`. You can specify a strategy using the `--strategy` flag:
+The resolver strategy determines how Doggo uses nameservers, whether they come 
from the system resolver configuration or are specified directly with `@host` / 
`--nameserver`. You can specify a strategy using the `--strategy` flag:
 
 ```bash
 $ doggo example.com --strategy=first
@@ -50,16 +50,17 @@
 
 Available strategies:
 
-- `all` (default): Use all nameservers listed in `/etc/resolv.conf`.
+- `all` (default): Use all nameservers.
 - `first`: Use only the first nameserver in the list.
 - `random`: Randomly choose one nameserver from the list for each query. This 
can help distribute the load across multiple nameservers.
+- `internal`: Use private IP nameservers only (RFC 1918 IPv4 or RFC 4193 IPv6 
ULA).
 
 ## Command-line Options
 
 ```bash
 --ndots=INT             Specify ndots parameter. Takes value from 
/etc/resolv.conf if using the system nameserver or 1 otherwise.
 --search                Use the search list defined in resolv.conf. Defaults 
to true. Set --search=false to disable search list.
---strategy=STRATEGY     Specify strategy to query nameservers listed in 
/etc/resolv.conf. Options: all, first, random. Defaults to all.
+--strategy=STRATEGY     Specify strategy to query nameservers. Options: all, 
first, random, internal. Defaults to all.
 --timeout=DURATION    Set the timeout for resolver responses (e.g., 5s, 400ms, 
1m).
 ```
 
@@ -84,6 +85,10 @@
    ```bash
    doggo example.com @1.1.1.1 @8.8.8.8
    ```
-   Note: When specifying nameservers directly, the system resolver 
configuration (including strategy) is not used.
 
-You can find more examples at [Examples](/guide/examples) section.
\ No newline at end of file
+5. Use only the first explicitly specified nameserver:
+   ```bash
+   doggo example.com --strategy=first @1.1.1.1 @8.8.8.8
+   ```
+
+You can find more examples at [Examples](/guide/examples) section.
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/doggo-1.1.6/go.mod new/doggo-1.1.7/go.mod
--- old/doggo-1.1.6/go.mod      2026-05-20 09:36:30.000000000 +0200
+++ new/doggo-1.1.7/go.mod      2026-05-22 15:05:55.000000000 +0200
@@ -1,6 +1,6 @@
 module github.com/mr-karan/doggo
 
-go 1.26.2
+go 1.26.3
 
 require (
        github.com/ameshkov/dnscrypt/v2 v2.4.0
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/doggo-1.1.6/internal/app/nameservers.go 
new/doggo-1.1.7/internal/app/nameservers.go
--- old/doggo-1.1.6/internal/app/nameservers.go 2026-05-20 09:36:30.000000000 
+0200
+++ new/doggo-1.1.7/internal/app/nameservers.go 2026-05-22 15:05:55.000000000 
+0200
@@ -30,6 +30,13 @@
                                app.Logger.Debug("Added nameserver", 
"nameserver", ns)
                        }
                }
+
+               var err error
+               app.Nameservers, err = 
app.applyNameserverStrategy(app.Nameservers, "explicit")
+               if err != nil {
+                       app.Logger.Error("error applying nameserver strategy", 
"error", err)
+                       return err
+               }
        }
 
        // If no nameservers were successfully loaded, fall back to system 
nameservers
@@ -43,7 +50,7 @@
 
 func (app *App) loadSystemNameservers() error {
        app.Logger.Debug("No user specified nameservers, falling back to system 
nameservers")
-       ns, ndots, search, err := getDefaultServers(app.QueryFlags.Strategy, 
app.QueryFlags.UseIPv4, app.QueryFlags.UseIPv6)
+       ns, ndots, search, err := app.getDefaultServers()
        if err != nil {
                app.Logger.Error("error fetching system default nameserver", 
"error", err)
                return fmt.Errorf("error fetching system default nameserver: 
%v", err)
@@ -314,82 +321,136 @@
        return filtered
 }
 
-func getDefaultServers(strategy string, useIPv4, useIPv6 bool) 
([]models.Nameserver, int, []string, error) {
+// applyNameserverStrategy narrows the supplied nameserver list according to
+// app.QueryFlags.Strategy and emits debug logs describing what it did. The
+// source argument labels the origin of the list (e.g. "explicit" for CLI
+// overrides, "system" for resolv.conf) so the same log lines can describe
+// both call sites without ambiguity.
+func (app *App) applyNameserverStrategy(nameservers []models.Nameserver, 
source string) ([]models.Nameserver, error) {
+       if len(nameservers) == 0 {
+               return nameservers, nil
+       }
+
+       strategy := app.QueryFlags.Strategy
+       app.Logger.Debug("Applying nameserver strategy",
+               "source", source,
+               "strategy", strategy,
+               "before_count", len(nameservers),
+               "before", nameservers,
+       )
+
+       switch strategy {
+       case "random":
+               src := rand.NewSource(time.Now().UnixNano())
+               rnd := rand.New(src)
+               selected := 
[]models.Nameserver{nameservers[rnd.Intn(len(nameservers))]}
+               app.logStrategyApplied(source, strategy, len(nameservers), 
selected)
+               return selected, nil
+
+       case "first":
+               selected := []models.Nameserver{nameservers[0]}
+               app.logStrategyApplied(source, strategy, len(nameservers), 
selected)
+               return selected, nil
+
+       case "internal":
+               internalServers := make([]models.Nameserver, 0)
+               for _, ns := range nameservers {
+                       if isPrivateIP(nameserverHost(ns)) {
+                               internalServers = append(internalServers, ns)
+                       }
+               }
+
+               if len(internalServers) == 0 {
+                       app.Logger.Debug("Nameserver strategy rejected all 
nameservers",
+                               "source", source,
+                               "strategy", strategy,
+                               "before_count", len(nameservers),
+                       )
+                       return nil, fmt.Errorf("no internal (private IP) 
nameservers found")
+               }
+
+               app.logStrategyApplied(source, strategy, len(nameservers), 
internalServers)
+               return internalServers, nil
+
+       default:
+               app.Logger.Debug("Nameserver strategy left nameservers 
unchanged",
+                       "source", source,
+                       "strategy", strategy,
+                       "count", len(nameservers),
+               )
+               return nameservers, nil
+       }
+}
+
+func (app *App) logStrategyApplied(source, strategy string, beforeCount int, 
after []models.Nameserver) {
+       app.Logger.Debug("Applied nameserver strategy",
+               "source", source,
+               "strategy", strategy,
+               "after_count", len(after),
+               "dropped_count", beforeCount-len(after),
+               "after", after,
+       )
+}
+
+func nameserverHost(ns models.Nameserver) string {
+       if u, err := url.Parse(ns.Address); err == nil && u.Hostname() != "" {
+               return u.Hostname()
+       }
+
+       host, _, err := net.SplitHostPort(ns.Address)
+       if err == nil {
+               return host
+       }
+
+       return ns.Address
+}
+
+func (app *App) getDefaultServers() ([]models.Nameserver, int, []string, 
error) {
        // Load nameservers from `/etc/resolv.conf`.
        dnsServers, ndots, search, err := config.GetDefaultServers()
        if err != nil {
                return nil, 0, nil, err
        }
 
+       app.Logger.Debug("Loaded system resolver configuration",
+               "nameservers", dnsServers,
+               "ndots", ndots,
+               "search", search,
+       )
+
        // Filter nameservers based on IPv4/IPv6 flags
-       dnsServers = filterNameserversByIPVersion(dnsServers, useIPv4, useIPv6)
+       beforeFilter := len(dnsServers)
+       dnsServers = filterNameserversByIPVersion(dnsServers, 
app.QueryFlags.UseIPv4, app.QueryFlags.UseIPv6)
+       if beforeFilter != len(dnsServers) {
+               app.Logger.Debug("Filtered system nameservers by IP version",
+                       "use_ipv4", app.QueryFlags.UseIPv4,
+                       "use_ipv6", app.QueryFlags.UseIPv6,
+                       "before_count", beforeFilter,
+                       "after_count", len(dnsServers),
+               )
+       }
 
        // If after filtering we have no servers, return an error
        if len(dnsServers) == 0 {
                ipVersion := "IPv4"
-               if useIPv6 {
+               if app.QueryFlags.UseIPv6 {
                        ipVersion = "IPv6"
                }
                return nil, ndots, search, fmt.Errorf("no %s nameservers found 
in system configuration", ipVersion)
        }
 
        servers := make([]models.Nameserver, 0, len(dnsServers))
-
-       switch strategy {
-       case "random":
-               // Create a new local random source and generator.
-               src := rand.NewSource(time.Now().UnixNano())
-               rnd := rand.New(src)
-
-               // Choose a random server from the list.
-               srv := dnsServers[rnd.Intn(len(dnsServers))]
+       for _, s := range dnsServers {
                ns := models.Nameserver{
                        Type:    models.UDPResolver,
-                       Address: net.JoinHostPort(srv, models.DefaultUDPPort),
+                       Address: net.JoinHostPort(s, models.DefaultUDPPort),
                }
                servers = append(servers, ns)
+       }
 
-       case "first":
-               // Choose the first from the list, always.
-               srv := dnsServers[0]
-               ns := models.Nameserver{
-                       Type:    models.UDPResolver,
-                       Address: net.JoinHostPort(srv, models.DefaultUDPPort),
-               }
-               servers = append(servers, ns)
-
-       case "internal":
-               // Filter for nameservers with private IPs only (RFC 1918 / RFC 
4193 ULA)
-               internalServers := make([]string, 0)
-               for _, srv := range dnsServers {
-                       if isPrivateIP(srv) {
-                               internalServers = append(internalServers, srv)
-                       }
-               }
-
-               // Return error if no internal servers found
-               if len(internalServers) == 0 {
-                       return nil, ndots, search, fmt.Errorf("no internal 
(private IP) nameservers found in system configuration")
-               }
-
-               // Return all internal servers
-               for _, s := range internalServers {
-                       ns := models.Nameserver{
-                               Type:    models.UDPResolver,
-                               Address: net.JoinHostPort(s, 
models.DefaultUDPPort),
-                       }
-                       servers = append(servers, ns)
-               }
-
-       default:
-               // Default behaviour is to load all nameservers.
-               for _, s := range dnsServers {
-                       ns := models.Nameserver{
-                               Type:    models.UDPResolver,
-                               Address: net.JoinHostPort(s, 
models.DefaultUDPPort),
-                       }
-                       servers = append(servers, ns)
-               }
+       servers, err = app.applyNameserverStrategy(servers, "system")
+       if err != nil {
+               return nil, ndots, search, fmt.Errorf("%w in system 
configuration", err)
        }
 
        return servers, ndots, search, nil
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/doggo-1.1.6/internal/app/nameservers_test.go 
new/doggo-1.1.7/internal/app/nameservers_test.go
--- old/doggo-1.1.6/internal/app/nameservers_test.go    1970-01-01 
01:00:00.000000000 +0100
+++ new/doggo-1.1.7/internal/app/nameservers_test.go    2026-05-22 
15:05:55.000000000 +0200
@@ -0,0 +1,74 @@
+package app
+
+import (
+       "io"
+       "log/slog"
+       "testing"
+
+       "github.com/mr-karan/doggo/pkg/models"
+)
+
+func TestLoadNameserversAppliesFirstStrategyToExplicitNameservers(t 
*testing.T) {
+       app := newTestApp()
+       app.QueryFlags.Nameservers = []string{"1.0.0.1", "1.1.1.1"}
+       app.QueryFlags.Strategy = "first"
+
+       if err := app.LoadNameservers(); err != nil {
+               t.Fatalf("LoadNameservers() error = %v", err)
+       }
+
+       want := []models.Nameserver{
+               {Address: "1.0.0.1:53", Type: models.UDPResolver},
+       }
+       assertNameservers(t, app.Nameservers, want)
+}
+
+func TestLoadNameserversAppliesInternalStrategyToExplicitNameservers(t 
*testing.T) {
+       app := newTestApp()
+       app.QueryFlags.Nameservers = []string{"1.1.1.1", "10.0.0.2", 
"tls://172.16.0.2"}
+       app.QueryFlags.Strategy = "internal"
+
+       if err := app.LoadNameservers(); err != nil {
+               t.Fatalf("LoadNameservers() error = %v", err)
+       }
+
+       want := []models.Nameserver{
+               {Address: "10.0.0.2:53", Type: models.UDPResolver},
+               {Address: "172.16.0.2:853", Type: models.DOTResolver},
+       }
+       assertNameservers(t, app.Nameservers, want)
+}
+
+func 
TestLoadNameserversReturnsErrorWhenExplicitInternalStrategyHasNoPrivateNameservers(t
 *testing.T) {
+       app := newTestApp()
+       app.QueryFlags.Nameservers = []string{"1.1.1.1", "8.8.8.8"}
+       app.QueryFlags.Strategy = "internal"
+
+       if err := app.LoadNameservers(); err == nil {
+               t.Fatal("LoadNameservers() error = nil, want error")
+       }
+}
+
+func newTestApp() App {
+       return App{
+               Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
+               QueryFlags: models.QueryFlags{
+                       Nameservers: []string{},
+               },
+               Nameservers: []models.Nameserver{},
+       }
+}
+
+func assertNameservers(t *testing.T, got, want []models.Nameserver) {
+       t.Helper()
+
+       if len(got) != len(want) {
+               t.Fatalf("len(nameservers) = %d, want %d: %#v", len(got), 
len(want), got)
+       }
+
+       for i := range want {
+               if got[i] != want[i] {
+                       t.Fatalf("nameservers[%d] = %#v, want %#v", i, got[i], 
want[i])
+               }
+       }
+}
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/doggo-1.1.6/pkg/resolvers/classic.go 
new/doggo-1.1.7/pkg/resolvers/classic.go
--- old/doggo-1.1.6/pkg/resolvers/classic.go    2026-05-20 09:36:30.000000000 
+0200
+++ new/doggo-1.1.7/pkg/resolvers/classic.go    2026-05-22 15:05:55.000000000 
+0200
@@ -135,6 +135,11 @@
        return rsp, nil
 }
 
+// Address implements the Resolver interface.
+func (r *ClassicResolver) Address() string {
+       return r.server
+}
+
 // Lookup implements the Resolver interface
 func (r *ClassicResolver) Lookup(ctx context.Context, questions 
[]dns.Question, flags QueryFlags) ([]Response, error) {
        return ConcurrentLookup(ctx, questions, flags, r.query, 
r.resolverOptions.Logger)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/doggo-1.1.6/pkg/resolvers/common.go 
new/doggo-1.1.7/pkg/resolvers/common.go
--- old/doggo-1.1.6/pkg/resolvers/common.go     2026-05-20 09:36:30.000000000 
+0200
+++ new/doggo-1.1.7/pkg/resolvers/common.go     2026-05-22 15:05:55.000000000 
+0200
@@ -2,6 +2,7 @@
 
 import (
        "context"
+       "errors"
        "log/slog"
        "sync"
 
@@ -11,55 +12,48 @@
 // QueryFunc represents the signature of a query function
 type QueryFunc func(ctx context.Context, question dns.Question, flags 
QueryFlags) (Response, error)
 
-// ConcurrentLookup performs concurrent DNS lookups
+// ConcurrentLookup performs concurrent DNS lookups across multiple questions
+// against a single resolver. It always waits for all in-flight goroutines so
+// completed work is never discarded when the context is cancelled or expires
+// mid-flight; callers receive whatever responses finished plus a joined error
+// describing any per-question failures.
 func ConcurrentLookup(ctx context.Context, questions []dns.Question, flags 
QueryFlags, queryFunc QueryFunc, logger *slog.Logger) ([]Response, error) {
        var wg sync.WaitGroup
        responses := make([]Response, len(questions))
-       errors := make([]error, len(questions))
-       done := make(chan struct{})
+       errs := make([]error, len(questions))
 
        for i, q := range questions {
                wg.Add(1)
                go func(i int, q dns.Question) {
                        defer wg.Done()
-                       select {
-                       case <-ctx.Done():
-                               errors[i] = ctx.Err()
-                       default:
-                               resp, err := queryFunc(ctx, q, flags)
-                               responses[i] = resp
-                               errors[i] = err
+                       if err := ctx.Err(); err != nil {
+                               errs[i] = err
+                               return
                        }
+                       resp, err := queryFunc(ctx, q, flags)
+                       responses[i] = resp
+                       errs[i] = err
                }(i, q)
        }
 
-       go func() {
-               wg.Wait()
-               close(done)
-       }()
-
-       select {
-       case <-ctx.Done():
-               return nil, ctx.Err()
-       case <-done:
-               // All goroutines have finished
-       }
+       wg.Wait()
 
-       // Collect non-nil responses and handle errors
        var validResponses []Response
+       var lookupErrs []error
        for i, resp := range responses {
-               if errors[i] != nil {
-                       if errors[i] != context.Canceled && errors[i] != 
context.DeadlineExceeded {
-                               logger.Error("error in lookup", "error", 
errors[i])
+               if errs[i] != nil {
+                       lookupErrs = append(lookupErrs, errs[i])
+                       if !errors.Is(errs[i], context.Canceled) && 
!errors.Is(errs[i], context.DeadlineExceeded) {
+                               logger.Error("error in lookup", "error", 
errs[i])
                        }
-               } else {
-                       validResponses = append(validResponses, resp)
+                       continue
                }
+               validResponses = append(validResponses, resp)
        }
 
-       if len(validResponses) == 0 && ctx.Err() != nil {
-               return nil, ctx.Err()
+       if len(validResponses) == 0 && len(lookupErrs) > 0 {
+               return nil, errors.Join(lookupErrs...)
        }
 
-       return validResponses, nil
+       return validResponses, errors.Join(lookupErrs...)
 }
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/doggo-1.1.6/pkg/resolvers/common_test.go 
new/doggo-1.1.7/pkg/resolvers/common_test.go
--- old/doggo-1.1.6/pkg/resolvers/common_test.go        1970-01-01 
01:00:00.000000000 +0100
+++ new/doggo-1.1.7/pkg/resolvers/common_test.go        2026-05-22 
15:05:55.000000000 +0200
@@ -0,0 +1,140 @@
+package resolvers
+
+import (
+       "context"
+       "errors"
+       "io"
+       "log/slog"
+       "sync/atomic"
+       "testing"
+       "time"
+
+       "github.com/miekg/dns"
+)
+
+func discardLogger() *slog.Logger {
+       return slog.New(slog.NewTextHandler(io.Discard, nil))
+}
+
+// TestConcurrentLookupReturnsCompletedWorkAfterContextExpiry guards against
+// the prior race where ctx.Done() in the parent select caused already-finished
+// goroutine results to be dropped on the floor. The first goroutine completes
+// successfully and cancels the parent context; the second blocks until
+// cancellation and then returns ctx.Err() — both must reach the caller.
+func TestConcurrentLookupReturnsCompletedWorkAfterContextExpiry(t *testing.T) {
+       good := dns.Question{Name: "good.example.com.", Qtype: dns.TypeA, 
Qclass: dns.ClassINET}
+       slow := dns.Question{Name: "slow.example.com.", Qtype: dns.TypeA, 
Qclass: dns.ClassINET}
+
+       ctx, cancel := context.WithCancel(context.Background())
+       t.Cleanup(cancel)
+
+       qf := func(ctx context.Context, q dns.Question, _ QueryFlags) 
(Response, error) {
+               if q.Name == good.Name {
+                       cancel()
+                       return Response{Questions: []Question{{Name: q.Name, 
Type: "A", Class: "IN"}}}, nil
+               }
+               // Block deterministically until the parent context is 
cancelled so
+               // this goroutine cannot finish before the cancel signal lands.
+               <-ctx.Done()
+               return Response{}, ctx.Err()
+       }
+
+       responses, err := ConcurrentLookup(ctx, []dns.Question{good, slow}, 
QueryFlags{}, qf, discardLogger())
+
+       if len(responses) != 1 {
+               t.Fatalf("len(responses) = %d, want 1 (the completed work)", 
len(responses))
+       }
+       if responses[0].Questions[0].Name != good.Name {
+               t.Fatalf("responses[0].Questions[0].Name = %q, want %q", 
responses[0].Questions[0].Name, good.Name)
+       }
+       if err == nil {
+               t.Fatal("err = nil, want joined error describing the cancelled 
question")
+       }
+       if !errors.Is(err, context.Canceled) {
+               t.Fatalf("err = %v, want errors.Is(err, context.Canceled)", err)
+       }
+}
+
+// TestConcurrentLookupJoinsPerQuestionErrors ensures partial per-question
+// failures are surfaced via errors.Join rather than discarded.
+func TestConcurrentLookupJoinsPerQuestionErrors(t *testing.T) {
+       a := dns.Question{Name: "a.example.com.", Qtype: dns.TypeA, Qclass: 
dns.ClassINET}
+       b := dns.Question{Name: "b.example.com.", Qtype: dns.TypeA, Qclass: 
dns.ClassINET}
+
+       errBoom := errors.New("boom")
+       errKaboom := errors.New("kaboom")
+
+       qf := func(_ context.Context, q dns.Question, _ QueryFlags) (Response, 
error) {
+               switch q.Name {
+               case a.Name:
+                       return Response{}, errBoom
+               case b.Name:
+                       return Response{}, errKaboom
+               }
+               return Response{}, errors.New("unexpected question")
+       }
+
+       responses, err := ConcurrentLookup(context.Background(), 
[]dns.Question{a, b}, QueryFlags{}, qf, discardLogger())
+       if len(responses) != 0 {
+               t.Fatalf("len(responses) = %d, want 0", len(responses))
+       }
+       if err == nil {
+               t.Fatal("err = nil, want joined error")
+       }
+       if !errors.Is(err, errBoom) || !errors.Is(err, errKaboom) {
+               t.Fatalf("err = %v, want both errBoom and errKaboom to be 
unwrappable", err)
+       }
+}
+
+// TestConcurrentLookupSurfacesPartialErrorAlongsideResponse verifies that a
+// successful question and a failed question in the same call both reach the
+// caller — the response in the slice, the error via errors.Join.
+func TestConcurrentLookupSurfacesPartialErrorAlongsideResponse(t *testing.T) {
+       good := dns.Question{Name: "good.example.com.", Qtype: dns.TypeA, 
Qclass: dns.ClassINET}
+       bad := dns.Question{Name: "bad.example.com.", Qtype: dns.TypeA, Qclass: 
dns.ClassINET}
+
+       errBoom := errors.New("boom")
+
+       qf := func(_ context.Context, q dns.Question, _ QueryFlags) (Response, 
error) {
+               if q.Name == good.Name {
+                       return Response{Questions: []Question{{Name: q.Name, 
Type: "A", Class: "IN"}}}, nil
+               }
+               return Response{}, errBoom
+       }
+
+       responses, err := ConcurrentLookup(context.Background(), 
[]dns.Question{good, bad}, QueryFlags{}, qf, discardLogger())
+       if len(responses) != 1 {
+               t.Fatalf("len(responses) = %d, want 1", len(responses))
+       }
+       if err == nil || !errors.Is(err, errBoom) {
+               t.Fatalf("err = %v, want errors.Is(err, errBoom)", err)
+       }
+}
+
+// TestConcurrentLookupWaitsForAllGoroutines makes sure we never short-circuit
+// the wait — a slow goroutine still gets to record its result.
+func TestConcurrentLookupWaitsForAllGoroutines(t *testing.T) {
+       q1 := dns.Question{Name: "fast.example.com.", Qtype: dns.TypeA, Qclass: 
dns.ClassINET}
+       q2 := dns.Question{Name: "slow.example.com.", Qtype: dns.TypeA, Qclass: 
dns.ClassINET}
+
+       var slowFinished atomic.Bool
+       qf := func(_ context.Context, q dns.Question, _ QueryFlags) (Response, 
error) {
+               if q.Name == q2.Name {
+                       time.Sleep(50 * time.Millisecond)
+                       slowFinished.Store(true)
+               }
+               return Response{Questions: []Question{{Name: q.Name, Type: "A", 
Class: "IN"}}}, nil
+       }
+
+       responses, err := ConcurrentLookup(context.Background(), 
[]dns.Question{q1, q2}, QueryFlags{}, qf, discardLogger())
+       if err != nil {
+               t.Fatalf("err = %v, want nil", err)
+       }
+       if len(responses) != 2 {
+               t.Fatalf("len(responses) = %d, want 2", len(responses))
+       }
+       if !slowFinished.Load() {
+               t.Fatal("ConcurrentLookup returned before slow goroutine 
finished")
+       }
+}
+
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/doggo-1.1.6/pkg/resolvers/dnscrypt.go 
new/doggo-1.1.7/pkg/resolvers/dnscrypt.go
--- old/doggo-1.1.6/pkg/resolvers/dnscrypt.go   2026-05-20 09:36:30.000000000 
+0200
+++ new/doggo-1.1.7/pkg/resolvers/dnscrypt.go   2026-05-22 15:05:55.000000000 
+0200
@@ -41,6 +41,11 @@
        }, nil
 }
 
+// Address implements the Resolver interface.
+func (r *DNSCryptResolver) Address() string {
+       return r.server
+}
+
 // Lookup implements the Resolver interface
 func (r *DNSCryptResolver) Lookup(ctx context.Context, questions 
[]dns.Question, flags QueryFlags) ([]Response, error) {
        return ConcurrentLookup(ctx, questions, flags, r.query, 
r.resolverOptions.Logger)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/doggo-1.1.6/pkg/resolvers/doh.go 
new/doggo-1.1.7/pkg/resolvers/doh.go
--- old/doggo-1.1.6/pkg/resolvers/doh.go        2026-05-20 09:36:30.000000000 
+0200
+++ new/doggo-1.1.7/pkg/resolvers/doh.go        2026-05-22 15:05:55.000000000 
+0200
@@ -151,6 +151,11 @@
        return rsp, nil
 }
 
+// Address implements the Resolver interface.
+func (r *DOHResolver) Address() string {
+       return r.server
+}
+
 // Lookup implements the Resolver interface
 func (r *DOHResolver) Lookup(ctx context.Context, questions []dns.Question, 
flags QueryFlags) ([]Response, error) {
        return ConcurrentLookup(ctx, questions, flags, r.query, 
r.resolverOptions.Logger)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/doggo-1.1.6/pkg/resolvers/doq.go 
new/doggo-1.1.7/pkg/resolvers/doq.go
--- old/doggo-1.1.6/pkg/resolvers/doq.go        2026-05-20 09:36:30.000000000 
+0200
+++ new/doggo-1.1.7/pkg/resolvers/doq.go        2026-05-22 15:05:55.000000000 
+0200
@@ -57,6 +57,11 @@
        }, nil
 }
 
+// Address implements the Resolver interface.
+func (r *DOQResolver) Address() string {
+       return r.server
+}
+
 // Lookup implements the Resolver interface
 func (r *DOQResolver) Lookup(ctx context.Context, questions []dns.Question, 
flags QueryFlags) ([]Response, error) {
        return ConcurrentLookup(ctx, questions, flags, r.query, 
r.resolverOptions.Logger)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/doggo-1.1.6/pkg/resolvers/lookup_error_test.go 
new/doggo-1.1.7/pkg/resolvers/lookup_error_test.go
--- old/doggo-1.1.6/pkg/resolvers/lookup_error_test.go  1970-01-01 
01:00:00.000000000 +0100
+++ new/doggo-1.1.7/pkg/resolvers/lookup_error_test.go  2026-05-22 
15:05:55.000000000 +0200
@@ -0,0 +1,37 @@
+package resolvers
+
+import (
+       "errors"
+       "testing"
+)
+
+func TestLookupErrorTagsNameserver(t *testing.T) {
+       inner := errors.New("connection refused")
+       le := &LookupError{Nameserver: "127.0.0.1:53", Err: inner}
+
+       got := le.Error()
+       want := "127.0.0.1:53: connection refused"
+       if got != want {
+               t.Fatalf("Error() = %q, want %q", got, want)
+       }
+
+       if !errors.Is(le, inner) {
+               t.Fatal("errors.Is should unwrap LookupError to the inner 
error")
+       }
+
+       var target *LookupError
+       if !errors.As(le, &target) {
+               t.Fatal("errors.As(&LookupError{}, &target) failed")
+       }
+       if target.Nameserver != "127.0.0.1:53" {
+               t.Fatalf("Nameserver = %q, want 127.0.0.1:53", 
target.Nameserver)
+       }
+}
+
+func TestLookupErrorWithoutNameserverFallsBackToInnerMessage(t *testing.T) {
+       inner := errors.New("oops")
+       le := &LookupError{Err: inner}
+       if le.Error() != "oops" {
+               t.Fatalf("Error() = %q, want %q", le.Error(), "oops")
+       }
+}
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/doggo-1.1.6/pkg/resolvers/resolver.go 
new/doggo-1.1.7/pkg/resolvers/resolver.go
--- old/doggo-1.1.6/pkg/resolvers/resolver.go   2026-05-20 09:36:30.000000000 
+0200
+++ new/doggo-1.1.7/pkg/resolvers/resolver.go   2026-05-22 15:05:55.000000000 
+0200
@@ -2,6 +2,7 @@
 
 import (
        "context"
+       "fmt"
        "log/slog"
        "time"
 
@@ -29,9 +30,30 @@
 // Client. Different types of providers can load
 // a DNS Resolver satisfying this interface.
 type Resolver interface {
+       // Address returns the nameserver identity used when reporting errors.
+       Address() string
        Lookup(ctx context.Context, questions []dns.Question, flags QueryFlags) 
([]Response, error)
 }
 
+// LookupError tags a resolver failure with the nameserver that produced it
+// so partial failures can be reported per-resolver instead of as an opaque
+// top-level error.
+type LookupError struct {
+       Nameserver string
+       Err        error
+}
+
+func (e *LookupError) Error() string {
+       if e.Nameserver == "" {
+               return e.Err.Error()
+       }
+       return fmt.Sprintf("%s: %v", e.Nameserver, e.Err)
+}
+
+func (e *LookupError) Unwrap() error {
+       return e.Err
+}
+
 // Response represents a custom output format
 // for DNS queries. It wraps metadata about the DNS query
 // and the DNS Answer as well.
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/doggo-1.1.6/pkg/resolvers/utils.go 
new/doggo-1.1.7/pkg/resolvers/utils.go
--- old/doggo-1.1.6/pkg/resolvers/utils.go      2026-05-20 09:36:30.000000000 
+0200
+++ new/doggo-1.1.7/pkg/resolvers/utils.go      2026-05-22 15:05:55.000000000 
+0200
@@ -128,18 +128,36 @@
 
        // If name has enough dots, try that first.
        if hasNdots {
-               names = append(names, name)
+               names = appendQuestionName(names, name)
        }
        for _, s := range searchList {
-               names = append(names, dns.Fqdn(name+s))
+               names = appendQuestionName(names, joinSearchDomain(name, s))
        }
        // If we didn't have enough dots, try after suffixes.
        if !hasNdots {
-               names = append(names, name)
+               names = appendQuestionName(names, name)
        }
        return names
 }
 
+func joinSearchDomain(name, searchDomain string) string {
+       if searchDomain == "." {
+               return name
+       }
+
+       return dns.Fqdn(strings.TrimSuffix(name, ".") + "." + 
strings.TrimSuffix(searchDomain, "."))
+}
+
+func appendQuestionName(names []string, name string) []string {
+       for _, existing := range names {
+               if existing == name {
+                       return names
+               }
+       }
+
+       return append(names, name)
+}
+
 // toUnicodeDomain converts a punycode domain name to Unicode.
 // If conversion fails, returns the original domain name.
 func toUnicodeDomain(name string) string {
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/doggo-1.1.6/pkg/resolvers/utils_test.go 
new/doggo-1.1.7/pkg/resolvers/utils_test.go
--- old/doggo-1.1.6/pkg/resolvers/utils_test.go 2026-05-20 09:36:30.000000000 
+0200
+++ new/doggo-1.1.7/pkg/resolvers/utils_test.go 2026-05-22 15:05:55.000000000 
+0200
@@ -1,6 +1,7 @@
 package resolvers
 
 import (
+       "reflect"
        "testing"
 
        "github.com/miekg/dns"
@@ -67,4 +68,52 @@
                        }
                })
        }
+}
+
+func TestConstructPossibleQuestionsWithRootSearchDomain(t *testing.T) {
+       tests := []struct {
+               name       string
+               qName      string
+               ndots      int
+               searchList []string
+               want       []string
+       }{
+               {
+                       name:       "root search does not append an extra 
trailing dot",
+                       qName:      "non-existent.test",
+                       ndots:      0,
+                       searchList: []string{"."},
+                       want:       []string{"non-existent.test."},
+               },
+               {
+                       name:       "root search is de-duplicated when original 
name is tried first",
+                       qName:      "non-existent.test",
+                       ndots:      1,
+                       searchList: []string{"."},
+                       want:       []string{"non-existent.test."},
+               },
+               {
+                       name:       "root search can follow regular search 
domains",
+                       qName:      "printer",
+                       ndots:      1,
+                       searchList: []string{"lan", "."},
+                       want:       []string{"printer.lan.", "printer."},
+               },
+               {
+                       name:       "fully qualified names ignore search 
domains",
+                       qName:      "example.com.",
+                       ndots:      1,
+                       searchList: []string{"."},
+                       want:       []string{"example.com."},
+               },
+       }
+
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       got := constructPossibleQuestions(tt.qName, tt.ndots, 
tt.searchList)
+                       if !reflect.DeepEqual(got, tt.want) {
+                               t.Fatalf("constructPossibleQuestions() = %#v, 
want %#v", got, tt.want)
+                       }
+               })
+       }
 }
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/doggo-1.1.6/web/handlers.go 
new/doggo-1.1.7/web/handlers.go
--- old/doggo-1.1.6/web/handlers.go     2026-05-20 09:36:30.000000000 +0200
+++ new/doggo-1.1.7/web/handlers.go     2026-05-22 15:05:55.000000000 +0200
@@ -132,28 +132,37 @@
                        defer wg.Done()
                        responses, err := r.Lookup(ctx, app.Questions, 
queryFlags)
                        mu.Lock()
+                       defer mu.Unlock()
+                       // Collect any responses the resolver produced even 
when err != nil
+                       // so partial successes (some questions answered, some 
failed) and
+                       // multi-resolver fan-outs where one resolver is 
unreachable do not
+                       // drop the responses we already have.
+                       allResponses = append(allResponses, responses...)
                        if err != nil {
-                               allErrors = append(allErrors, err)
-                       } else {
-                               allResponses = append(allResponses, 
responses...)
+                               allErrors = append(allErrors, 
&resolvers.LookupError{
+                                       Nameserver: r.Address(),
+                                       Err:        err,
+                               })
                        }
-                       mu.Unlock()
                }(resolver)
        }
 
        wg.Wait()
 
-       if len(allErrors) > 0 {
-               app.Logger.Error("errors looking up DNS records", "errors", 
allErrors)
-               sendErrorResponse(w, "Error looking up for records.", 
http.StatusInternalServerError, nil)
-               return
-       }
-
        if len(allResponses) == 0 {
+               if len(allErrors) > 0 {
+                       app.Logger.Error("errors looking up DNS records", 
"errors", allErrors)
+                       sendErrorResponse(w, "Error looking up for records.", 
http.StatusInternalServerError, nil)
+                       return
+               }
                sendErrorResponse(w, "No records found.", http.StatusNotFound, 
nil)
                return
        }
 
+       if len(allErrors) > 0 {
+               app.Logger.Warn("partial lookup failure", "errors", allErrors)
+       }
+
        sendResponse(w, http.StatusOK, allResponses)
 }
 

++++++ doggo.obsinfo ++++++
--- /var/tmp/diff_new_pack.nk3Dgi/_old  2026-05-28 23:12:51.533668895 +0200
+++ /var/tmp/diff_new_pack.nk3Dgi/_new  2026-05-28 23:12:51.553669718 +0200
@@ -1,5 +1,5 @@
 name: doggo
-version: 1.1.6
-mtime: 1779262590
-commit: 2bcf2f719d2017db2d525cd8b31c65f4b8b54e69
+version: 1.1.7
+mtime: 1779455155
+commit: b400c464b445c79805a9987475f862357ce7f49d
 

++++++ vendor.tar.xz ++++++

Reply via email to