diff --git a/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolverPal.Windows.cs b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolverPal.Windows.cs index 1bda15f24a5809..9dc2cde4457176 100644 --- a/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolverPal.Windows.cs +++ b/src/libraries/System.Net.NameResolution/src/System/Net/DnsResolverPal.Windows.cs @@ -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); - 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 s_hostName = new Lazy(() => + { + try { return NameResolutionPal.GetHostName(); } + catch (SocketException) { return null; } + }); + + private static bool IsSynchronouslyCompletingQueryName(string name) + { + if (IPAddress.IsValid(name)) + { + return true; + } + + ReadOnlySpan 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 DnsQueryEx(IPEndPoint[] servers, bool async, string name, ushort queryType, CancellationToken cancellationToken) { @@ -300,10 +326,11 @@ private static Task 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; } diff --git a/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverTest.cs b/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverTest.cs index bde5ebc150fc1d..af5770f601cfad 100644 --- a/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverTest.cs +++ b/src/libraries/System.Net.NameResolution/tests/FunctionalTests/DnsResolverTest.cs @@ -117,6 +117,28 @@ private static async Task> ResolvePtr(bool async, DnsResolv private static async Task> Static_ResolveAddresses(bool async, string name) => async ? await Dns.ResolveAddressesAsync(name) : Dns.ResolveAddresses(name); + public static TheoryData SynchronouslyCompletingQueryNames() + { + string hostName = Dns.GetHostName(); + return new TheoryData + { + { 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))] @@ -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> 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> task = ResolveAddresses(async, r, name); DnsResult 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}."); + } + } } }