diff --git a/src/PlaywrightNative.Tests/Library/NetworkTimeout/NetworkTimeoutTests.cs b/src/PlaywrightNative.Tests/Library/NetworkTimeout/NetworkTimeoutTests.cs
new file mode 100644
index 00000000..e973db30
--- /dev/null
+++ b/src/PlaywrightNative.Tests/Library/NetworkTimeout/NetworkTimeoutTests.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 NetworkTimeoutTests : 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.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/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);
+ }
+ }
+ }
+}