From 5f20fa31c86d508433c9e04ffbff1da3f3fd0ba8 Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 15 Oct 2026 16:38:24 +0000 Subject: [PATCH] resolver: a query cut short by the caller's context records no reason (closes #298) When the caller's context ends, the next query returns ErrContextCanceled before anything is sent. querySingleType recorded that as a network error, which outranks a timeout, so a nameserver that had only timed out could be reported as "network error: context canceled". It now records no reason for it. Model: opus-5-5 --- TODO.md | 3 ++ internal/resolver/iterative.go | 4 +++ internal/resolver/iterative_internal_test.go | 32 ++++++++++++++++++++ 3 files changed, 39 insertions(+) diff --git a/TODO.md b/TODO.md index 9375cbd..74b090e 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,9 @@ milestone: https://git.eeqj.de/sneak/dnswatcher/milestone/7 # Completed Steps +- 2026-10-15: a query cut short by its caller's context records no reason about + the nameserver, so one whose only failures were timeouts is `timeout`, not a + network error (closes #298). - 2026-10-15: the resolver no longer says its query timeout grows on each try; every try waits the same 2 seconds, and the unused `timeoutMultiplier` is gone (closes #296). diff --git a/internal/resolver/iterative.go b/internal/resolver/iterative.go index 4d495f7..72290d7 100644 --- a/internal/resolver/iterative.go +++ b/internal/resolver/iterative.go @@ -863,6 +863,10 @@ func (r *Resolver) querySingleType( if err != nil { err = fmt.Errorf("query %s @%s: %w", hostname, nsIP, err) } + case errors.Is(err, ErrContextCanceled): + // The caller's context ended and cut the query short. That + // says nothing about the nameserver, so no reason is + // recorded. case isTimeout(err): state.gotTimeout = true case errors.Is(err, ErrRefused): diff --git a/internal/resolver/iterative_internal_test.go b/internal/resolver/iterative_internal_test.go index f0f399c..31655aa 100644 --- a/internal/resolver/iterative_internal_test.go +++ b/internal/resolver/iterative_internal_test.go @@ -1,6 +1,8 @@ package resolver import ( + "context" + "log/slog" "strconv" "syscall" "testing" @@ -132,3 +134,33 @@ func TestReadReply(t *testing.T) { }) } } + +// TestQuerySingleType_ContextEnded asks a nameserver about one record +// type with a context that has already ended, so no query is sent. A +// query the context cut short says nothing about the nameserver and +// records no reason: a nameserver whose earlier query timed out is +// still reported as timed out, not as a network error. +func TestQuerySingleType_ContextEnded(t *testing.T) { + t.Parallel() + + r := NewFromLogger(slog.New(slog.DiscardHandler)) + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + resp := &NameserverResponse{Records: map[string][]string{}, Status: StatusOK} + + // The query for an earlier record type timed out. + state := queryState{gotTimeout: true} + + _, err := r.querySingleType( + ctx, []string{"192.0.2.1"}, "example.com.", dns.TypeA, resp, &state, + ) + require.ErrorIs(t, err, ErrContextCanceled) + assert.Equal(t, queryState{gotTimeout: true}, state) + + classifyResponse(resp, state) + + assert.Equal(t, StatusTimeout, resp.Status) + assert.Equal(t, "all queries timed out", resp.Error) +}