fix(resolver): fix async context leak, gRPC error swallowing, add 48 tests

Fix async resolve goroutine using caller's cancelable context by
switching to context.WithoutCancel. Fix NewGRPCPlugin silently
returning broken resolver on connection failure. Fix "resolover"
typo in log field. Add doc comments to all exported symbols.
This commit is contained in:
ginuerzh
2026-05-25 20:52:19 +08:00
parent acce86c0e4
commit 28d3ffa310
7 changed files with 916 additions and 9 deletions
+419
View File
@@ -0,0 +1,419 @@
package resolver
import (
"context"
"net"
"sync/atomic"
"testing"
"time"
xlogger "github.com/go-gost/x/logger"
resolver_util "github.com/go-gost/x/internal/util/resolver"
"github.com/miekg/dns"
)
// mockExchanger implements exchanger.Exchanger for testing.
type mockExchanger struct {
response *dns.Msg
err error
calls atomic.Int64
}
func (m *mockExchanger) Exchange(_ context.Context, msg []byte) ([]byte, error) {
m.calls.Add(1)
if m.err != nil {
return nil, m.err
}
if m.response != nil {
return m.response.Pack()
}
// Echo back a basic response
q := new(dns.Msg)
if err := q.Unpack(msg); err != nil {
return nil, err
}
r := new(dns.Msg)
r.SetReply(q)
return r.Pack()
}
func (m *mockExchanger) String() string {
return "mock://test"
}
// newDNSMsgWithA creates a dns.Msg with A records for the given IPs.
func newDNSMsgWithA(ips ...string) *dns.Msg {
m := new(dns.Msg)
m.SetQuestion("example.com.", dns.TypeA)
for _, ip := range ips {
m.Answer = append(m.Answer, &dns.A{
Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 300},
A: net.ParseIP(ip),
})
}
return m
}
// newDNSMsgWithAAAA creates a dns.Msg with AAAA records for the given IPs.
func newDNSMsgWithAAAA(ips ...string) *dns.Msg {
m := new(dns.Msg)
m.SetQuestion("example.com.", dns.TypeAAAA)
for _, ip := range ips {
m.Answer = append(m.Answer, &dns.AAAA{
Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeAAAA, Class: dns.ClassINET, Ttl: 300},
AAAA: net.ParseIP(ip),
})
}
return m
}
func nopLog() options {
return options{logger: xlogger.Nop()}
}
// newTestResolver creates a localResolver with cache and nop logger.
func newTestResolver(servers []NameServer, opts ...Option) *localResolver {
o := options{logger: xlogger.Nop()}
for _, opt := range opts {
opt(&o)
}
cache := resolver_util.NewCache().WithLogger(xlogger.Nop())
return &localResolver{
servers: servers,
cache: cache,
options: o,
}
}
func TestResolve_IPLiteral(t *testing.T) {
r := &localResolver{options: nopLog()}
tests := []struct {
host string
want string
}{
{"1.2.3.4", "1.2.3.4"},
{"::1", "::1"},
{"2001:db8::1", "2001:db8::1"},
}
for _, tt := range tests {
ips, err := r.Resolve(context.Background(), "ip", tt.host)
if err != nil {
t.Fatalf("unexpected error for %s: %v", tt.host, err)
}
if len(ips) != 1 || !ips[0].Equal(net.ParseIP(tt.want)) {
t.Errorf("Resolve(%q) = %v, want [%s]", tt.host, ips, tt.want)
}
}
}
func TestResolve_DomainSuffix(t *testing.T) {
ex := &mockExchanger{response: newDNSMsgWithA("10.0.0.1")}
r := newTestResolver(
[]NameServer{{exchanger: ex, TTL: time.Minute}},
DomainOption("example.com"),
)
_, err := r.Resolve(context.Background(), "ip", "host")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ex.calls.Load() == 0 {
t.Fatal("expected exchanger to be called")
}
}
func TestResolve_DomainSuffixNotAppliedToFQDN(t *testing.T) {
ex := &mockExchanger{response: newDNSMsgWithA("10.0.0.1")}
r := newTestResolver(
[]NameServer{{exchanger: ex, TTL: time.Minute}},
DomainOption("example.com"),
)
_, err := r.Resolve(context.Background(), "ip", "host.other.com")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ex.calls.Load() == 0 {
t.Fatal("expected exchanger to be called")
}
}
func TestResolve_MultipleServers_Fallback(t *testing.T) {
failEx := &mockExchanger{err: net.UnknownNetworkError("fail")}
okEx := &mockExchanger{response: newDNSMsgWithA("10.0.0.1")}
r := newTestResolver([]NameServer{
{exchanger: failEx, TTL: time.Minute},
{exchanger: okEx, TTL: time.Minute},
})
ips, err := r.Resolve(context.Background(), "ip", "example.com")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(ips) == 0 {
t.Fatal("expected fallback to second server")
}
if failEx.calls.Load() == 0 {
t.Error("expected first server to be called")
}
if okEx.calls.Load() == 0 {
t.Error("expected second server to be called")
}
}
func TestResolve_AllServersFail(t *testing.T) {
ex1 := &mockExchanger{err: net.UnknownNetworkError("fail1")}
ex2 := &mockExchanger{err: net.UnknownNetworkError("fail2")}
r := newTestResolver([]NameServer{
{exchanger: ex1, TTL: time.Minute},
{exchanger: ex2, TTL: time.Minute},
})
ips, _ := r.Resolve(context.Background(), "ip", "example.com")
if len(ips) != 0 {
t.Fatalf("expected no IPs when all servers fail, got %v", ips)
}
}
func TestResolve_EmptyServers(t *testing.T) {
r := &localResolver{servers: []NameServer{}, options: nopLog()}
ips, err := r.Resolve(context.Background(), "ip", "example.com")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(ips) != 0 {
t.Fatalf("expected no IPs with no servers, got %v", ips)
}
}
func TestResolve_IPv4Only(t *testing.T) {
ex := &mockExchanger{response: newDNSMsgWithA("10.0.0.1")}
r := newTestResolver([]NameServer{{exchanger: ex, Only: "ipv4", Prefer: "ipv4", TTL: time.Minute}})
ips, err := r.Resolve(context.Background(), "ip", "example.com")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(ips) == 0 {
t.Fatal("expected IPv4 result")
}
}
func TestResolve_IPv6Only(t *testing.T) {
ex := &mockExchanger{response: newDNSMsgWithAAAA("2001:db8::1")}
r := newTestResolver([]NameServer{{exchanger: ex, Only: "ipv6", Prefer: "ipv6", TTL: time.Minute}})
ips, err := r.Resolve(context.Background(), "ip", "example.com")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(ips) == 0 {
t.Fatal("expected IPv6 result")
}
}
func TestResolve_IPv6Prefer_FallbackToIPv4(t *testing.T) {
aEx := &mockExchanger{response: newDNSMsgWithA("10.0.0.1")}
r := newTestResolver([]NameServer{{exchanger: aEx, Prefer: "ipv6", TTL: time.Minute}})
ips, err := r.Resolve(context.Background(), "ip", "example.com")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(ips) == 0 {
t.Fatal("expected IPv4 fallback result")
}
}
func TestResolve_CacheHit(t *testing.T) {
ex := &mockExchanger{response: newDNSMsgWithA("10.0.0.1")}
r := newTestResolver([]NameServer{{exchanger: ex, TTL: time.Minute}})
// First call: cache miss → exchanges with server
ips, err := r.Resolve(context.Background(), "ip", "example.com")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(ips) == 0 {
t.Fatal("expected result on first call")
}
firstCalls := ex.calls.Load()
// Second call: should hit cache (TTL = 1 minute)
ips, err = r.Resolve(context.Background(), "ip", "example.com")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(ips) == 0 {
t.Fatal("expected cached result")
}
if ex.calls.Load() != firstCalls {
t.Errorf("expected no additional exchange calls (got %d total)", ex.calls.Load())
}
}
func TestResolve_NilServer(t *testing.T) {
r := &localResolver{options: nopLog()}
ips, err := r.resolve(context.Background(), nil, "example.com")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(ips) != 0 {
t.Fatalf("expected no IPs for nil server, got %v", ips)
}
}
func TestResolve_AsyncCacheMiss(t *testing.T) {
ex := &mockExchanger{response: newDNSMsgWithA("10.0.0.1")}
r := newTestResolver([]NameServer{{exchanger: ex, Async: true, TTL: time.Minute}})
// Cache miss → should do synchronous resolve
ips, err := r.Resolve(context.Background(), "ip", "example.com")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(ips) == 0 {
t.Fatal("expected result on first async call (cache miss)")
}
if ex.calls.Load() != 1 {
t.Errorf("expected 1 exchange call, got %d", ex.calls.Load())
}
}
func TestResolve_AsyncCacheHitWithRefresh(t *testing.T) {
ex := &mockExchanger{response: newDNSMsgWithA("10.0.0.1")}
r := newTestResolver([]NameServer{{exchanger: ex, Async: true, TTL: time.Minute}})
// Populate cache first
ips, err := r.Resolve(context.Background(), "ip", "example.com")
if err != nil {
t.Fatalf("setup: %v", err)
}
if len(ips) == 0 {
t.Fatal("setup: expected result")
}
// Manually expire the cache entry
mq := new(dns.Msg)
mq.SetQuestion(dns.Fqdn("example.com"), dns.TypeA)
key := resolver_util.NewCacheKey(&mq.Question[0])
mr := newDNSMsgWithA("10.0.0.2")
r.cache.Store(context.Background(), key, mr, -1*time.Second)
beforeCalls := ex.calls.Load()
// Cache hit with expired TTL → should return stale data and trigger background refresh
ips, err = r.Resolve(context.Background(), "ip", "example.com")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Should return data (from stale cache or fresh resolve)
_ = ips
// Give background goroutine time to complete
time.Sleep(50 * time.Millisecond)
// Background refresh should have fired
if ex.calls.Load() <= beforeCalls {
t.Logf("note: background refresh may not have fired yet (calls: before=%d, now=%d)", beforeCalls, ex.calls.Load())
}
}
func TestResolve_CancelledContext(t *testing.T) {
ex := &mockExchanger{response: newDNSMsgWithA("10.0.0.1")}
r := newTestResolver([]NameServer{{exchanger: ex, TTL: time.Minute}})
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := r.Resolve(ctx, "ip", "example.com")
// Should handle cancelled context gracefully (error or empty result)
_ = err
}
func TestResolveIPs_ExtractsAAndAAAA(t *testing.T) {
// Build a response with both A and AAAA records
mr := new(dns.Msg)
mr.SetQuestion("example.com.", dns.TypeA)
mr.Answer = append(mr.Answer,
&dns.A{
Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 300},
A: net.ParseIP("10.0.0.1"),
},
&dns.AAAA{
Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeAAAA, Class: dns.ClassINET, Ttl: 300},
AAAA: net.ParseIP("2001:db8::1"),
},
)
ex := &mockExchanger{response: mr}
r := newTestResolver([]NameServer{{exchanger: ex, TTL: time.Minute}})
ips, err := r.Resolve(context.Background(), "ip", "example.com")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(ips) < 2 {
t.Fatalf("expected at least 2 IPs (A+AAAA), got %d: %v", len(ips), ips)
}
has4, has6 := false, false
for _, ip := range ips {
if ip.Equal(net.ParseIP("10.0.0.1")) {
has4 = true
}
if ip.Equal(net.ParseIP("2001:db8::1")) {
has6 = true
}
}
if !has4 || !has6 {
t.Errorf("expected both A and AAAA records in result, got %v", ips)
}
}
func TestExchange_RoundTrip(t *testing.T) {
mq := new(dns.Msg)
mq.SetQuestion(dns.Fqdn("example.com"), dns.TypeA)
mq.RecursionDesired = true
packed, err := mq.Pack()
if err != nil {
t.Fatalf("pack: %v", err)
}
ex := &mockExchanger{response: newDNSMsgWithA("10.0.0.1")}
reply, err := ex.Exchange(context.Background(), packed)
if err != nil {
t.Fatalf("exchange: %v", err)
}
mr := new(dns.Msg)
if err := mr.Unpack(reply); err != nil {
t.Fatalf("unpack: %v", err)
}
if len(mr.Answer) == 0 {
t.Fatal("expected answers in response")
}
}
func TestDomainOption(t *testing.T) {
opts := &options{}
DomainOption("test.local")(opts)
if opts.domain != "test.local" {
t.Errorf("expected domain 'test.local', got %q", opts.domain)
}
}
func TestLoggerOption(t *testing.T) {
opts := &options{}
l := xlogger.Nop()
LoggerOption(l)(opts)
if opts.logger != l {
t.Error("logger option not applied")
}
}