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
Original file line number Diff line number Diff line change
Expand Up @@ -287,9 +287,35 @@ private static unsafe void ParseAdditionalAddresses(IntPtr head, ref Dictionary<
// 22000+). See https://dblohm7.ca/blog/2022/05/06/dnsqueryex-needs-love/.
private static readonly bool s_asyncSyncCompletionBug = !OperatingSystem.IsWindowsVersionAtLeast(10, 0, 22000);
Comment thread
rzikm marked this conversation as resolved.

private static bool IsLocalhostName(string name) =>
string.Equals(name, "localhost", StringComparison.OrdinalIgnoreCase) ||
string.Equals(name, "localhost.", StringComparison.OrdinalIgnoreCase);
// Cache the local hostname to avoid a native gethostname call on every query.
// null means the lookup failed at startup; we treat that as "use synchronous path" (safe fallback).
private static readonly Lazy<string?> s_hostName = new Lazy<string?>(() =>
{
try { return NameResolutionPal.GetHostName(); }
catch (SocketException) { return null; }
});

private static bool IsSynchronouslyCompletingQueryName(string name)
{
if (IPAddress.IsValid(name))
{
return true;
}

ReadOnlySpan<char> normalizedName = name.AsSpan().TrimEnd('.');

if (normalizedName.Equals("localhost", StringComparison.OrdinalIgnoreCase) ||
normalizedName.Equals("loopback", StringComparison.OrdinalIgnoreCase) ||
normalizedName.Equals("..DnsServers", StringComparison.OrdinalIgnoreCase) ||
normalizedName.Equals("..localmachine", StringComparison.OrdinalIgnoreCase))
{
return true;
}

string? hostName = s_hostName.Value;
// If hostname lookup failed, use the synchronous path as a safe fallback.
return hostName is null || normalizedName.Equals(hostName, StringComparison.OrdinalIgnoreCase);
}

private static Task<DnsQueryRawResult> DnsQueryEx(IPEndPoint[] servers, bool async, string name, ushort queryType, CancellationToken cancellationToken)
{
Expand All @@ -300,10 +326,11 @@ private static Task<DnsQueryRawResult> DnsQueryEx(IPEndPoint[] servers, bool asy

if (async && s_asyncSyncCompletionBug)
{
// On affected Windows versions "localhost" always trips the synchronous-completion
// bug, so resolve it on the synchronous path up front and route every other
// asynchronous query through a wrapper that retries synchronously if it hits the bug.
if (IsLocalhostName(name))
// On affected Windows versions a small set of special names always trips the
// synchronous-completion bug, so resolve them on the synchronous path up front and
// route every other asynchronous query through a wrapper that retries synchronously
// if it hits the bug.
if (IsSynchronouslyCompletingQueryName(name))
{
async = false;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,28 @@ private static async Task<DnsResult<PtrRecord>> ResolvePtr(bool async, DnsResolv
private static async Task<DnsResult<AddressRecord>> Static_ResolveAddresses(bool async, string name)
=> async ? await Dns.ResolveAddressesAsync(name) : Dns.ResolveAddresses(name);

public static TheoryData<bool, string> SynchronouslyCompletingQueryNames()
{
string hostName = Dns.GetHostName();
return new TheoryData<bool, string>
{
{ false, "localhost" },
{ true, "localhost" },
{ false, "loopback" },
{ true, "loopback" },
{ false, "..DnsServers" },
{ true, "..DnsServers" },
{ false, "..localmachine" },
{ true, "..localmachine" },
{ false, "127.0.0.1" },
{ true, "127.0.0.1" },
{ false, "::1" },
{ true, "::1" },
{ false, hostName },
{ true, hostName },
};
}

// ---- Windows network tests (require outbound DNS) ----

[ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))]
Expand All @@ -129,32 +151,41 @@ public async Task DnsResolver_PreCanceledToken_ReturnsCanceled()
}

// Regression test for the Windows 10 DnsQueryEx bug where an asynchronous query
// that the OS can satisfy synchronously (for example "localhost") returns
// that the OS can satisfy synchronously (for example localhost, loopback, IP
// literals, the local host name, and a few Windows special names) returns
// ERROR_SUCCESS inline and never invokes the registered completion callback.
// If the implementation waited for that callback it would hang forever; the PAL
// must instead detect the synchronous completion (any status other than
// DNS_REQUEST_PENDING) and surface the result directly.
// See https://dblohm7.ca/blog/2022/05/06/dnsqueryex-needs-love/.
[ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsWindows))]
[InlineData(false)]
[InlineData(true)]
public async Task ResolveAddresses_SynchronouslyCompletingQuery_DoesNotHang(bool async)
[MemberData(nameof(SynchronouslyCompletingQueryNames))]
public async Task ResolveAddresses_SynchronouslyCompletingQuery_DoesNotHang(bool async, string name)
{
using DnsResolver r = new DnsResolver();

// "localhost" can be answered without any network round-trip, which is what
// triggers the synchronous-completion path inside DnsQueryEx. A short timeout
// turns the "callback never fires" hang into a test failure rather than letting
// the run stall.
Task<DnsResult<AddressRecord>> task = ResolveAddresses(async, r, "localhost");
// These names can be answered without the normal asynchronous callback path,
// which is what triggers the synchronous-completion path inside DnsQueryEx.
// A short timeout turns the "callback never fires" hang into a test failure
// rather than letting the run stall.
Task<DnsResult<AddressRecord>> task = ResolveAddresses(async, r, name);
DnsResult<AddressRecord> result = await task.WaitAsync(TimeSpan.FromSeconds(30));

Assert.Equal(DnsResponseCode.NoError, result.ResponseCode);

Assert.NotEmpty(result.Records);
foreach (AddressRecord rec in result.Records)
// ..DnsServers and ..localmachine may return a non-NoError code on machines without
// DNS servers configured; treat that as acceptable. But when the query succeeds the
// records list must be non-empty — that is the key correctness check for the
// async-path sync-fallback: a NoError response must come with actual records.
if (result.ResponseCode == DnsResponseCode.NoError)
{
Assert.True(IPAddress.IsLoopback(rec.Address), $"Expected a loopback address but got {rec.Address}.");
Assert.NotEmpty(result.Records);

if (name is "localhost" or "loopback" or "127.0.0.1" or "::1")
{
foreach (AddressRecord rec in result.Records)
{
Assert.True(IPAddress.IsLoopback(rec.Address), $"Expected a loopback address but got {rec.Address}.");
}
}
}
}

Expand Down
Loading