Compare commits

..
1 Commits
Author SHA1 Message Date
sneak b401ae5086 resolver: a query cut short by the caller's context records no reason (closes #298)
check / check (push) Canceled after 0s
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.

A try the context's deadline cuts short ends as a timeout. When the
context has ended before a further try, tryExchange now returns that
try's error instead of ErrContextCanceled, so such a nameserver is
reported as timed out, not as answering.

Model: opus-5-5
2026-10-15 16:52:18 +00:00
4 changed files with 47 additions and 14 deletions
+4 -3
View File
@@ -19,9 +19,10 @@ 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: a query its caller's context ends before it is sent records no
reason about the nameserver, and a try the context's deadline cuts short is a
timeout, so a nameserver whose only failures were timeouts is `timeout`, not a
network error or `ok` (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).
+11 -4
View File
@@ -62,7 +62,10 @@ func checkCtx(ctx context.Context) error {
}
// tryExchange sends msg to addr over UDP, up to maxRetries times, until
// a reply comes. Every try waits the same queryTimeoutDuration.
// a reply comes. Every try waits the same queryTimeoutDuration, or less
// when ctx's deadline comes first. When ctx has ended before a further
// try, it returns the error of the try already made, such as a timeout,
// or ErrContextCanceled when none was made.
func (r *Resolver) tryExchange(
ctx context.Context,
msg *dns.Msg,
@@ -74,7 +77,11 @@ func (r *Resolver) tryExchange(
for range maxRetries {
if checkCtx(ctx) != nil {
return nil, ErrContextCanceled
if err == nil {
return nil, ErrContextCanceled
}
return nil, err
}
resp, _, err = r.client.ExchangeContext(ctx, msg, addr)
@@ -864,8 +871,8 @@ func (r *Resolver) querySingleType(
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
// The caller's context ended before the query was sent.
// That says nothing about the nameserver, so no reason is
// recorded.
case isTimeout(err):
state.gotTimeout = true
+3 -3
View File
@@ -137,9 +137,9 @@ 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.
// query the context ended before it was sent 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()
+29 -4
View File
@@ -1380,10 +1380,10 @@ func TestQueryNameserverIP_Timeout(t *testing.T) {
r := newTestResolver(t)
// Nothing answers at 192.0.2.1, a documentation address. The
// resolver tries each query twice, and the first try gives up
// after two seconds. A deadline that ends during the first try
// makes the status vary from run to run between error and
// timeout, so the deadline must outlast the first try.
// resolver tries each query twice, and each try gives up after two
// seconds. This deadline ends during the second try of the first
// record type; TestQueryNameserverIP_DeadlineDuringFirstTry covers
// one that ends during the first.
ctx, cancel := context.WithTimeout(
context.Background(), 3*time.Second,
)
@@ -1399,6 +1399,31 @@ func TestQueryNameserverIP_Timeout(t *testing.T) {
assert.NotEmpty(t, resp.Error)
}
// TestQueryNameserverIP_DeadlineDuringFirstTry gives a query to
// 192.0.2.1, where nothing answers, a deadline that ends during its
// first try. That try ends at the deadline as a timeout, so the
// nameserver is reported as timed out, not as answering.
func TestQueryNameserverIP_DeadlineDuringFirstTry(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
// Well under the two seconds one try waits.
ctx, cancel := context.WithTimeout(
context.Background(), 500*time.Millisecond,
)
t.Cleanup(cancel)
resp, err := r.QueryNameserverIP(
ctx, "unreachable.test.", "192.0.2.1",
"example.com",
)
require.NoError(t, err)
assert.Equal(t, resolver.StatusTimeout, resp.Status)
assert.NotEmpty(t, resp.Error)
}
// TestQueryNameserverIP_CancelledLogsNothing cancels the context while
// a query to 192.0.2.1, where nothing answers, is waiting for a reply,
// as shutdown does. The query was cut short, not failed, so nothing is