From 704e610a3569ee0b8e0323a599f018a435e5a1de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dar=C3=ADo=20Kondratiuk?= Date: Tue, 6 Oct 2026 02:52:10 -0300 Subject: [PATCH 1/3] Port network-timeout and happy-eyeballs fetch tests Browser downloads now treat PLAYWRIGHT_DOWNLOAD_CONNECTION_TIMEOUT as a socket idle timeout that starts at socket creation, like upstream httpRequest. A stalled connect or a stalled response fails after that many ms, while a long but active download no longer hits a whole-request timeout. The default is upstream's 30 s. Co-Authored-By: Claude Opus 5.5 --- ...serContextFetchHappyEyeballsParityTests.cs | 30 ++- .../LibraryNetworkTimeoutParityTests.cs | 114 ++++++++++ src/PlaywrightNative/BrowserFetcher.cs | 38 ++-- .../Helpers/HttpRequestHelper.cs | 199 ++++++++++++++++++ 4 files changed, 358 insertions(+), 23 deletions(-) create mode 100644 src/PlaywrightNative.Tests/LibraryNetworkTimeoutParityTests.cs create mode 100644 src/PlaywrightNative/Helpers/HttpRequestHelper.cs diff --git a/src/PlaywrightNative.Tests/LibraryBrowserContextFetchHappyEyeballsParityTests.cs b/src/PlaywrightNative.Tests/LibraryBrowserContextFetchHappyEyeballsParityTests.cs index e4cb612e..5f73339a 100644 --- a/src/PlaywrightNative.Tests/LibraryBrowserContextFetchHappyEyeballsParityTests.cs +++ b/src/PlaywrightNative.Tests/LibraryBrowserContextFetchHappyEyeballsParityTests.cs @@ -25,10 +25,8 @@ namespace PlaywrightNative.Tests { /// /// Official library/browsercontext-fetch-happy-eyeballs.spec.ts - /// titles that do not need Node __testHookLookup. Skipped - /// (Node-only internals): get should work, - /// get should work on request fixture, - /// https post should work with ignoreHTTPSErrors option. + /// parity. Titles that drive the Node __testHookLookup DNS hook + /// are ignored. /// [TestFixture] [NonParallelizable] @@ -79,6 +77,30 @@ public async Task StopOwnedServerAsync() } } + [PlaywrightTest("browsercontext-fetch-happy-eyeballs.spec.ts", "get should work")] + [Test] + [Timeout(TestConstants.DefaultTestTimeout)] + public void GetShouldWork() + { + Assert.Ignore("Node __testHookLookup DNS hook"); + } + + [PlaywrightTest("browsercontext-fetch-happy-eyeballs.spec.ts", "get should work on request fixture")] + [Test] + [Timeout(TestConstants.DefaultTestTimeout)] + public void GetShouldWorkOnRequestFixture() + { + Assert.Ignore("Node __testHookLookup DNS hook"); + } + + [PlaywrightTest("browsercontext-fetch-happy-eyeballs.spec.ts", "https post should work with ignoreHTTPSErrors option")] + [Test] + [Timeout(TestConstants.DefaultTestTimeout)] + public void HttpsPostShouldWorkWithIgnoreHTTPSErrorsOption() + { + Assert.Ignore("Node __testHookLookup DNS hook"); + } + [PlaywrightTest("browsercontext-fetch-happy-eyeballs.spec.ts", "should work with ip6 and port as the host")] [Test] [Timeout(TestConstants.DefaultTestTimeout)] diff --git a/src/PlaywrightNative.Tests/LibraryNetworkTimeoutParityTests.cs b/src/PlaywrightNative.Tests/LibraryNetworkTimeoutParityTests.cs new file mode 100644 index 00000000..ac5ff3f3 --- /dev/null +++ b/src/PlaywrightNative.Tests/LibraryNetworkTimeoutParityTests.cs @@ -0,0 +1,114 @@ +/* + * Copyright (c) Microsoft Corporation. + * Modifications copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +using System; +using System.Diagnostics; +using System.Globalization; +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Threading.Tasks; +using NUnit.Framework; +using PlaywrightNative.Helpers; +using PlaywrightNative.NUnit; + +namespace PlaywrightNative.Tests +{ + /// + /// Official library/network-timeout.spec.ts parity. Upstream drives + /// the internal utils.httpRequest; the .NET twin is + /// , which the browser fetcher uses. + /// File-level mode !== 'default' does not apply here. + /// + [TestFixture] + public class LibraryNetworkTimeoutParityTests : PlaywrightTestEx + { + [PlaywrightTest("network-timeout.spec.ts", "httpRequest should honor socketTimeout while connecting")] + [Test] + [Timeout(TestConstants.DefaultTestTimeout)] + public async Task HttpRequestShouldHonorSocketTimeoutWhileConnecting() + { + // 203.0.113.1 is TEST-NET-3 (RFC 5737), guaranteed to be unroutable, so the + // TCP connection stalls instead of failing. Make sure the requested timeout + // governs from the start. + const int socketTimeout = 8000; + Stopwatch stopwatch = Stopwatch.StartNew(); + Exception error = await HttpRequestErrorAsync("http://203.0.113.1/index.html", socketTimeout).ConfigureAwait(false); + long elapsed = stopwatch.ElapsedMilliseconds; + if (!error.Message.Contains("timed out after", StringComparison.Ordinal)) + { + // Some networks actively refuse TEST-NET addresses instead of blackholing them. + Assert.Ignore($"environment does not stall connections to TEST-NET: {error.Message}"); + } + + Assert.That(error.Message, Does.Contain($"timed out after {socketTimeout}ms")); + Assert.That(elapsed, Is.GreaterThanOrEqualTo(socketTimeout - 1000)); + } + + [PlaywrightTest("network-timeout.spec.ts", "httpRequest should honor socketTimeout for stalled responses")] + [Test] + [Timeout(TestConstants.DefaultTestTimeout)] + public async Task HttpRequestShouldHonorSocketTimeoutForStalledResponses() + { + // Accepts connections but never responds. + TcpListener server = new TcpListener(IPAddress.Loopback, 0); + server.Start(); + int port = ((IPEndPoint)server.LocalEndpoint).Port; + const int socketTimeout = 1500; + Stopwatch stopwatch = Stopwatch.StartNew(); + try + { + Exception error = await HttpRequestErrorAsync( + "http://127.0.0.1:" + port.ToString(CultureInfo.InvariantCulture) + "/index.html", + socketTimeout).ConfigureAwait(false); + long elapsed = stopwatch.ElapsedMilliseconds; + Assert.That(error.Message, Does.Contain($"timed out after {socketTimeout}ms")); + Assert.That(elapsed, Is.GreaterThanOrEqualTo(socketTimeout - 500)); + Assert.That(elapsed, Is.LessThan(socketTimeout * 3)); + } + finally + { + server.Stop(); + } + } + + [PlaywrightTest("network-timeout.spec.ts", "httpRequest should honor ipv4first result order")] + [Test] + [Timeout(TestConstants.DefaultTestTimeout)] + public void HttpRequestShouldHonorIpv4firstResultOrder() + { + Assert.Ignore("Node dns.promises.lookup / dns.setDefaultResultOrder hook"); + } + + private static async Task HttpRequestErrorAsync(string url, int socketTimeout) + { + using HttpClient client = new HttpClient(HttpRequestHelper.CreateHandler(socketTimeout), disposeHandler: true) + { + Timeout = System.Threading.Timeout.InfiniteTimeSpan, + }; + using HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, url); + try + { + using HttpResponseMessage response = await HttpRequestHelper.SendAsync(client, request, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false); + return new InvalidOperationException("unexpected response"); + } + catch (HttpRequestException ex) + { + return ex; + } + } + } +} diff --git a/src/PlaywrightNative/BrowserFetcher.cs b/src/PlaywrightNative/BrowserFetcher.cs index f3f0a77c..5828c4ea 100644 --- a/src/PlaywrightNative/BrowserFetcher.cs +++ b/src/PlaywrightNative/BrowserFetcher.cs @@ -11,9 +11,12 @@ using System.Net; using System.Net.Http; using System.Runtime.InteropServices; +using System.Security.Cryptography.X509Certificates; +using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Microsoft.Playwright; +using PlaywrightNative.Helpers; namespace PlaywrightNative { @@ -92,7 +95,7 @@ public async Task CanDownloadAsync(string buildId) try { using HttpRequestMessage request = new(HttpMethod.Head, url); - using HttpResponseMessage response = await client.SendAsync(request).ConfigureAwait(false); + using HttpResponseMessage response = await HttpRequestHelper.SendAsync(client, request, HttpCompletionOption.ResponseContentRead).ConfigureAwait(false); if (response.IsSuccessStatusCode) { return true; @@ -335,10 +338,15 @@ private HttpClient GetHttpClient() private HttpClient BuildHttpClient() { - HttpClientHandler handler = new() - { - CheckCertificateRevocationList = true, - }; + // PLAYWRIGHT_DOWNLOAD_CONNECTION_TIMEOUT is a misnomer, it actually controls the socket's + // max idle timeout. Unfortunately, we cannot rename it without breaking existing user workflows. + string timeoutEnv = Environment.GetEnvironmentVariable(ConnectionTimeoutEnvVar); + int socketTimeout = !string.IsNullOrEmpty(timeoutEnv) && int.TryParse(timeoutEnv, out int timeoutMs) && timeoutMs > 0 + ? timeoutMs + : HttpRequestHelper.NetDefaultTimeout; + + SocketsHttpHandler handler = HttpRequestHelper.CreateHandler(socketTimeout); + handler.SslOptions.CertificateRevocationCheckMode = X509RevocationMode.Online; if (WebProxy != null) { @@ -346,20 +354,11 @@ private HttpClient BuildHttpClient() handler.UseProxy = true; } - HttpClient client = new(handler, disposeHandler: true); - - string timeoutEnv = Environment.GetEnvironmentVariable(ConnectionTimeoutEnvVar); - if (!string.IsNullOrEmpty(timeoutEnv) && int.TryParse(timeoutEnv, out int timeoutMs) && timeoutMs > 0) + // The socket idle timeout governs; large downloads must not hit a whole-request timeout. + return new HttpClient(handler, disposeHandler: true) { - client.Timeout = TimeSpan.FromMilliseconds(timeoutMs); - } - else - { - // Downloads are large; allow plenty of time by default. - client.Timeout = TimeSpan.FromMinutes(30); - } - - return client; + Timeout = Timeout.InfiniteTimeSpan, + }; } private string[] DownloadUrls(string buildId) @@ -384,7 +383,8 @@ private async Task DownloadToFileAsync(string buildId, string zipPath, ILogger l { log?.LogInformation("Downloading {Browser} {BuildId} from {Url} (attempt {Attempt})", Browser, buildId, url, attempt + 1); - using HttpResponseMessage response = await client.GetAsync(new Uri(url), HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false); + using HttpRequestMessage request = new(HttpMethod.Get, url); + using HttpResponseMessage response = await HttpRequestHelper.SendAsync(client, request, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { int code = (int)response.StatusCode; diff --git a/src/PlaywrightNative/Helpers/HttpRequestHelper.cs b/src/PlaywrightNative/Helpers/HttpRequestHelper.cs new file mode 100644 index 00000000..4dc78432 --- /dev/null +++ b/src/PlaywrightNative/Helpers/HttpRequestHelper.cs @@ -0,0 +1,199 @@ +/* + * Copyright (c) Microsoft Corporation. + * Modifications copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +using System; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; + +namespace PlaywrightNative.Helpers +{ + /// + /// Port of upstream httpRequest (packages/utils/network.ts): + /// socketTimeout is a socket idle timeout that applies from socket + /// creation, so it covers both a stalled TCP connect and a stalled response. + /// + internal static class HttpRequestHelper + { + /// Upstream NET_DEFAULT_TIMEOUT. + internal const int NetDefaultTimeout = 30_000; + + internal static SocketsHttpHandler CreateHandler(int socketTimeout) + => new SocketsHttpHandler + { + ConnectCallback = (context, cancellationToken) => + ConnectAsync(context.DnsEndPoint, socketTimeout, cancellationToken), + }; + + internal static async Task SendAsync( + HttpClient client, + HttpRequestMessage request, + HttpCompletionOption completionOption, + CancellationToken cancellationToken = default) + { + try + { + return await client.SendAsync(request, completionOption, cancellationToken).ConfigureAwait(false); + } + catch (HttpRequestException ex) when (FindTimeout(ex) is TimeoutException timeout) + { + throw new HttpRequestException($"Request to {request.RequestUri} {timeout.Message}", ex); + } + } + + private static TimeoutException FindTimeout(Exception exception) + { + for (Exception current = exception; current != null; current = current.InnerException) + { + if (current is TimeoutException timeout) + { + return timeout; + } + } + + return null; + } + + private static IOException TimedOut(int socketTimeout) + { + string message = $"timed out after {socketTimeout}ms"; + return new IOException(message, new TimeoutException(message)); + } + + private static async ValueTask ConnectAsync( + DnsEndPoint endPoint, + int socketTimeout, + CancellationToken cancellationToken) + { + Socket socket = new Socket(SocketType.Stream, ProtocolType.Tcp) + { + NoDelay = true, + }; + try + { + using (CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)) + { + timeout.CancelAfter(socketTimeout); + try + { + await socket.ConnectAsync(endPoint, timeout.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + throw TimedOut(socketTimeout); + } + } + + Stream result = new SocketTimeoutStream(new NetworkStream(socket, ownsSocket: true), socketTimeout); + socket = null; + return result; + } + finally + { + socket?.Dispose(); + } + } + + /// + /// Fails a read or write that sees no socket activity for + /// socketTimeout ms (Node socket.setTimeout). + /// + private sealed class SocketTimeoutStream : Stream + { + private readonly Stream _inner; + private readonly int _socketTimeout; + + public SocketTimeoutStream(Stream inner, int socketTimeout) + { + _inner = inner; + _socketTimeout = socketTimeout; + } + + public override bool CanRead => _inner.CanRead; + + public override bool CanSeek => false; + + public override bool CanWrite => _inner.CanWrite; + + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() => _inner.Flush(); + + public override Task FlushAsync(CancellationToken cancellationToken) => _inner.FlushAsync(cancellationToken); + + public override int Read(byte[] buffer, int offset, int count) => _inner.Read(buffer, offset, count); + + public override void Write(byte[] buffer, int offset, int count) => _inner.Write(buffer, offset, count); + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => WriteAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(_socketTimeout); + try + { + return await _inner.ReadAsync(buffer, timeout.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + throw TimedOut(_socketTimeout); + } + } + + public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(_socketTimeout); + try + { + await _inner.WriteAsync(buffer, timeout.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + throw TimedOut(_socketTimeout); + } + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _inner.Dispose(); + } + + base.Dispose(disposing); + } + } + } +} From 28172a0c008ecdec53e2c0e2170539e48c637314 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dar=C3=ADo=20Kondratiuk?= Date: Tue, 6 Oct 2026 17:45:59 -0300 Subject: [PATCH 2/3] Move network-timeout tests to Library/NetworkTimeout/NetworkTimeoutTests.cs Co-Authored-By: Claude Opus 5.5 --- .../NetworkTimeout/NetworkTimeoutTests.cs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/PlaywrightNative.Tests/{LibraryNetworkTimeoutParityTests.cs => Library/NetworkTimeout/NetworkTimeoutTests.cs} (100%) diff --git a/src/PlaywrightNative.Tests/LibraryNetworkTimeoutParityTests.cs b/src/PlaywrightNative.Tests/Library/NetworkTimeout/NetworkTimeoutTests.cs similarity index 100% rename from src/PlaywrightNative.Tests/LibraryNetworkTimeoutParityTests.cs rename to src/PlaywrightNative.Tests/Library/NetworkTimeout/NetworkTimeoutTests.cs From ea268f2f54b4b282987063cc3e848fc6d43a412e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dar=C3=ADo=20Kondratiuk?= Date: Tue, 6 Oct 2026 17:46:07 -0300 Subject: [PATCH 3/3] Rename network-timeout test class to NetworkTimeoutTests Co-Authored-By: Claude Opus 5.5 --- .../Library/NetworkTimeout/NetworkTimeoutTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PlaywrightNative.Tests/Library/NetworkTimeout/NetworkTimeoutTests.cs b/src/PlaywrightNative.Tests/Library/NetworkTimeout/NetworkTimeoutTests.cs index ac5ff3f3..e973db30 100644 --- a/src/PlaywrightNative.Tests/Library/NetworkTimeout/NetworkTimeoutTests.cs +++ b/src/PlaywrightNative.Tests/Library/NetworkTimeout/NetworkTimeoutTests.cs @@ -34,7 +34,7 @@ namespace PlaywrightNative.Tests /// File-level mode !== 'default' does not apply here. /// [TestFixture] - public class LibraryNetworkTimeoutParityTests : PlaywrightTestEx + public class NetworkTimeoutTests : PlaywrightTestEx { [PlaywrightTest("network-timeout.spec.ts", "httpRequest should honor socketTimeout while connecting")] [Test]