A game framework written with osu! in mind.
1// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
2// See the LICENCE file in the repository root for full licence text.
3
4#if NET5_0
5using System.Net.Sockets;
6#endif
7using System;
8using System.Collections.Generic;
9using System.IO;
10using System.Net;
11using System.Net.Http;
12using System.Net.Http.Headers;
13using System.Text;
14using System.Threading;
15using System.Threading.Tasks;
16using JetBrains.Annotations;
17using osu.Framework.Bindables;
18using osu.Framework.Extensions.ExceptionExtensions;
19using osu.Framework.Logging;
20
21namespace osu.Framework.IO.Network
22{
23 public class WebRequest : IDisposable
24 {
25 internal const int MAX_RETRIES = 1;
26
27 /// <summary>
28 /// Whether non-SSL requests should be allowed. Defaults to disabled.
29 /// In the default state, http:// requests will be automatically converted to https://.
30 /// </summary>
31 public bool AllowInsecureRequests;
32
33 /// <summary>
34 /// Invoked when a response has been received, but not data has been received.
35 /// </summary>
36 public event Action Started;
37
38 /// <summary>
39 /// Invoked when the <see cref="WebRequest"/> has finished successfully.
40 /// </summary>
41 public event Action Finished;
42
43 /// <summary>
44 /// Invoked when the <see cref="WebRequest"/> has failed.
45 /// </summary>
46 public event Action<Exception> Failed;
47
48 /// <summary>
49 /// Invoked when the download progress has changed.
50 /// </summary>
51 public event Action<long, long> DownloadProgress;
52
53 /// <summary>
54 /// Invoked when the upload progress has changed.
55 /// </summary>
56 public event Action<long, long> UploadProgress;
57
58 /// <summary>
59 /// Whether the <see cref="WebRequest"/> was aborted due to an exception or a user abort request.
60 /// </summary>
61 public bool Aborted { get; private set; }
62
63 private bool completed;
64
65 /// <summary>
66 /// Whether the <see cref="WebRequest"/> has been run.
67 /// </summary>
68 public bool Completed
69 {
70 get => completed;
71 private set
72 {
73 completed = value;
74 if (!completed) return;
75
76 // WebRequests can only be used once - no need to keep events bound
77 // This helps with disposal in PerformAsync usages
78 Started = null;
79 Finished = null;
80 Failed = null;
81 DownloadProgress = null;
82 UploadProgress = null;
83 }
84 }
85
86 /// <summary>
87 /// The URL of this request.
88 /// </summary>
89 public string Url;
90
91 /// <summary>
92 /// Query string parameters.
93 /// </summary>
94 private readonly Dictionary<string, string> queryParameters = new Dictionary<string, string>();
95
96 /// <summary>
97 /// Form parameters.
98 /// </summary>
99 private readonly Dictionary<string, string> formParameters = new Dictionary<string, string>();
100
101 /// <summary>
102 /// FILE parameters.
103 /// </summary>
104 private readonly IDictionary<string, byte[]> files = new Dictionary<string, byte[]>();
105
106 /// <summary>
107 /// The request headers.
108 /// </summary>
109 private readonly IDictionary<string, string> headers = new Dictionary<string, string>();
110
111 public const int DEFAULT_TIMEOUT = 10000;
112
113 public HttpMethod Method = HttpMethod.Get;
114
115 /// <summary>
116 /// The amount of time from last sent or received data to trigger a timeout and abort the request.
117 /// </summary>
118 public int Timeout = DEFAULT_TIMEOUT;
119
120 /// <summary>
121 /// The type of content expected by this web request.
122 /// </summary>
123 protected virtual string Accept => string.Empty;
124
125 /// <summary>
126 /// The value of the User-agent HTTP header.
127 /// </summary>
128 protected virtual string UserAgent => "osu-framework";
129
130 internal int RetryCount { get; private set; }
131
132 /// <summary>
133 /// Whether this request should internally retry (up to <see cref="MAX_RETRIES"/> times) on a timeout before throwing an exception.
134 /// </summary>
135 public bool AllowRetryOnTimeout { get; set; } = true;
136
137 private static readonly HttpClient client = new HttpClient(
138#if NET5_0
139 new SocketsHttpHandler
140 {
141 AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate,
142 ConnectCallback = onConnect,
143 }
144#else
145 new HttpClientHandler { AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate }
146#endif
147 )
148 {
149 // Timeout is controlled manually through cancellation tokens because
150 // HttpClient does not properly timeout while reading chunked data
151 Timeout = System.Threading.Timeout.InfiniteTimeSpan
152 };
153
154 private static readonly Logger logger = Logger.GetLogger(LoggingTarget.Network);
155
156 public WebRequest(string url = null, params object[] args)
157 {
158 if (!string.IsNullOrEmpty(url))
159 Url = args.Length == 0 ? url : string.Format(url, args);
160 }
161
162 private int responseBytesRead;
163
164 private const int buffer_size = 32768;
165 private byte[] buffer;
166
167 private MemoryStream rawContent;
168
169 public string ContentType;
170
171 protected virtual Stream CreateOutputStream() => new MemoryStream();
172
173 public Stream ResponseStream;
174
175 /// <summary>
176 /// Retrieve the full response body as a UTF8 encoded string.
177 /// </summary>
178 /// <returns>The response body.</returns>
179 [CanBeNull]
180 public string GetResponseString()
181 {
182 try
183 {
184 ResponseStream.Seek(0, SeekOrigin.Begin);
185 StreamReader r = new StreamReader(ResponseStream, Encoding.UTF8);
186 return r.ReadToEnd();
187 }
188 catch
189 {
190 return null;
191 }
192 }
193
194 /// <summary>
195 /// Retrieve the full response body as an array of bytes.
196 /// </summary>
197 /// <returns>The response body.</returns>
198 public byte[] GetResponseData()
199 {
200 try
201 {
202 byte[] data = new byte[ResponseStream.Length];
203 ResponseStream.Seek(0, SeekOrigin.Begin);
204 ResponseStream.Read(data, 0, data.Length);
205 return data;
206 }
207 catch
208 {
209 return null;
210 }
211 }
212
213 public HttpResponseHeaders ResponseHeaders => response.Headers;
214
215 private CancellationToken? userToken;
216 private CancellationTokenSource abortToken;
217 private CancellationTokenSource timeoutToken;
218
219 private LengthTrackingStream requestStream;
220 private HttpResponseMessage response;
221
222 private long contentLength => requestStream?.Length ?? 0;
223
224 private const string form_boundary = "-----------------------------28947758029299";
225
226 private const string form_content_type = "multipart/form-data; boundary=" + form_boundary;
227
228 /// <summary>
229 /// Performs the request asynchronously.
230 /// </summary>
231 public Task PerformAsync() => PerformAsync(default);
232
233 /// <summary>
234 /// Performs the request asynchronously.
235 /// </summary>
236 /// <param name="cancellationToken">A token to cancel the request.</param>
237 public async Task PerformAsync(CancellationToken cancellationToken)
238 {
239 if (Completed)
240 throw new InvalidOperationException($"The {nameof(WebRequest)} has already been run.");
241
242 try
243 {
244 await internalPerform(cancellationToken).ConfigureAwait(false);
245 }
246 catch (AggregateException ae)
247 {
248 ae.RethrowAsSingular();
249 }
250 }
251
252 private async Task internalPerform(CancellationToken cancellationToken = default)
253 {
254 var url = Url;
255
256 if (!AllowInsecureRequests && !url.StartsWith(@"https://", StringComparison.Ordinal))
257 {
258 logger.Add($"Insecure request was automatically converted to https ({Url})");
259 url = @"https://" + url.Replace(@"http://", @"");
260 }
261
262 // If a user token already exists, keep it. Otherwise, take on the previous user token, as this could be a retry of the request.
263 userToken ??= cancellationToken;
264 cancellationToken = userToken.Value;
265
266 using (abortToken ??= new CancellationTokenSource()) // don't recreate if already non-null. is used during retry logic.
267 using (timeoutToken = new CancellationTokenSource())
268 using (var linkedToken = CancellationTokenSource.CreateLinkedTokenSource(abortToken.Token, timeoutToken.Token, cancellationToken))
269 {
270 try
271 {
272 PrePerform();
273
274 HttpRequestMessage request;
275
276 StringBuilder requestParameters = new StringBuilder();
277 foreach (var p in queryParameters)
278 requestParameters.Append($@"{p.Key}={Uri.EscapeDataString(p.Value)}&");
279 string requestString = requestParameters.ToString().TrimEnd('&');
280 url = string.IsNullOrEmpty(requestString) ? url : $"{url}?{requestString}";
281
282 if (Method == HttpMethod.Get)
283 {
284 if (files.Count > 0)
285 throw new InvalidOperationException($"Cannot use {nameof(AddFile)} in a GET request. Please set the {nameof(Method)} to POST.");
286
287 request = new HttpRequestMessage(HttpMethod.Get, url);
288 }
289 else
290 {
291 request = new HttpRequestMessage(Method, url);
292
293 Stream postContent = null;
294
295 if (rawContent != null)
296 {
297 if (formParameters.Count > 0)
298 throw new InvalidOperationException($"Cannot use {nameof(AddRaw)} in conjunction with form parameters");
299 if (files.Count > 0)
300 throw new InvalidOperationException($"Cannot use {nameof(AddRaw)} in conjunction with {nameof(AddFile)}");
301
302 postContent = new MemoryStream();
303 rawContent.Position = 0;
304
305 await rawContent.CopyToAsync(postContent, linkedToken.Token).ConfigureAwait(false);
306
307 postContent.Position = 0;
308 }
309 else if (formParameters.Count > 0 || files.Count > 0)
310 {
311 if (!string.IsNullOrEmpty(ContentType) && ContentType != form_content_type)
312 throw new InvalidOperationException($"Cannot use custom {nameof(ContentType)} in a POST request with form/file parameters.");
313
314 ContentType = form_content_type;
315
316 var formData = new MultipartFormDataContent(form_boundary);
317
318 foreach (var p in formParameters)
319 formData.Add(new StringContent(p.Value), p.Key);
320
321 foreach (var p in files)
322 {
323 var byteContent = new ByteArrayContent(p.Value);
324 byteContent.Headers.Add("Content-Type", "application/octet-stream");
325 formData.Add(byteContent, p.Key, p.Key);
326 }
327
328#if NET5_0
329 postContent = await formData.ReadAsStreamAsync(linkedToken.Token).ConfigureAwait(false);
330#else
331 postContent = await formData.ReadAsStreamAsync().ConfigureAwait(false);
332#endif
333 }
334
335 if (postContent != null)
336 {
337 requestStream = new LengthTrackingStream(postContent);
338 requestStream.BytesRead.ValueChanged += e =>
339 {
340 reportForwardProgress();
341 UploadProgress?.Invoke(e.NewValue, contentLength);
342 };
343
344 request.Content = new StreamContent(requestStream);
345 if (!string.IsNullOrEmpty(ContentType))
346 request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(ContentType);
347 }
348 }
349
350 request.Headers.UserAgent.TryParseAdd(UserAgent);
351
352 if (!string.IsNullOrEmpty(Accept))
353 request.Headers.Accept.TryParseAdd(Accept);
354
355 foreach (var kvp in headers)
356 request.Headers.Add(kvp.Key, kvp.Value);
357
358 reportForwardProgress();
359
360 using (request)
361 {
362 response = await client
363 .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, linkedToken.Token)
364 .ConfigureAwait(false);
365
366 ResponseStream = CreateOutputStream();
367
368 if (Method == HttpMethod.Get)
369 {
370 //GETs are easy
371 await beginResponse(linkedToken.Token).ConfigureAwait(false);
372 }
373 else
374 {
375 reportForwardProgress();
376 UploadProgress?.Invoke(0, contentLength);
377
378 await beginResponse(linkedToken.Token).ConfigureAwait(false);
379 }
380 }
381 }
382 catch (Exception) when (timeoutToken.IsCancellationRequested)
383 {
384 Complete(new WebException($"Request to {url} timed out after {timeSinceLastAction / 1000} seconds idle (read {responseBytesRead} bytes, retried {RetryCount} times).",
385 WebExceptionStatus.Timeout));
386 }
387 catch (Exception) when (abortToken.IsCancellationRequested || cancellationToken.IsCancellationRequested)
388 {
389 onAborted();
390 }
391 catch (Exception e)
392 {
393 if (Completed)
394 // we may be coming from one of the exception blocks handled above (as Complete will rethrow all exceptions).
395 throw;
396
397 Complete(e);
398 }
399 }
400
401 void onAborted()
402 {
403 // Aborting via the cancellation token will not set the correct aborted/completion states. Make sure they're set here.
404 Abort();
405
406 Complete(new WebException($"Request to {url} aborted by user.", WebExceptionStatus.RequestCanceled));
407 }
408 }
409
410 /// <summary>
411 /// Performs the request synchronously.
412 /// </summary>
413 public void Perform()
414 {
415 try
416 {
417 PerformAsync().Wait();
418 }
419 catch (AggregateException ae)
420 {
421 ae.RethrowAsSingular();
422 }
423 }
424
425 /// <summary>
426 /// Task to run direct before performing the request.
427 /// </summary>
428 protected virtual void PrePerform()
429 {
430 }
431
432 private async Task beginResponse(CancellationToken cancellationToken)
433 {
434#if NET5_0
435 using (var responseStream = await response.Content
436 .ReadAsStreamAsync(cancellationToken)
437 .ConfigureAwait(false))
438#else
439 using (var responseStream = await response.Content
440 .ReadAsStreamAsync()
441 .ConfigureAwait(false))
442#endif
443 {
444 reportForwardProgress();
445 Started?.Invoke();
446
447 buffer = new byte[buffer_size];
448
449 while (true)
450 {
451 cancellationToken.ThrowIfCancellationRequested();
452
453 int read = await responseStream
454 .ReadAsync(buffer.AsMemory(), cancellationToken)
455 .ConfigureAwait(false);
456
457 reportForwardProgress();
458
459 if (read > 0)
460 {
461 await ResponseStream
462 .WriteAsync(buffer.AsMemory(0, read), cancellationToken)
463 .ConfigureAwait(false);
464
465 responseBytesRead += read;
466 DownloadProgress?.Invoke(responseBytesRead, response.Content.Headers.ContentLength ?? responseBytesRead);
467 }
468 else
469 {
470 ResponseStream.Seek(0, SeekOrigin.Begin);
471 Complete();
472 break;
473 }
474 }
475 }
476 }
477
478 protected virtual void Complete(Exception e = null)
479 {
480 if (Aborted)
481 return;
482
483 var we = e as WebException;
484
485 bool allowRetry = AllowRetryOnTimeout;
486 bool wasTimeout = false;
487
488 if (e != null)
489 wasTimeout = we?.Status == WebExceptionStatus.Timeout;
490 else if (!response.IsSuccessStatusCode)
491 {
492 e = new WebException(response.StatusCode.ToString());
493
494 switch (response.StatusCode)
495 {
496 case HttpStatusCode.GatewayTimeout:
497 case HttpStatusCode.RequestTimeout:
498 wasTimeout = true;
499 break;
500 }
501 }
502
503 allowRetry &= wasTimeout;
504
505 if (e != null)
506 {
507 if (allowRetry && RetryCount < MAX_RETRIES && responseBytesRead == 0)
508 {
509 RetryCount++;
510
511 logger.Add($@"Request to {Url} failed with {e} (retrying {RetryCount}/{MAX_RETRIES}).");
512
513 //do a retry
514 internalPerform().Wait();
515 return;
516 }
517
518 logger.Add($"Request to {Url} failed with {e}.");
519
520 if (ResponseStream?.CanSeek == true && ResponseStream.Length > 0)
521 {
522 // in the case we fail a request, spitting out the response in the log is quite helpful.
523 ResponseStream.Seek(0, SeekOrigin.Begin);
524
525 using (StreamReader r = new StreamReader(ResponseStream, new UTF8Encoding(false, true), true, 1024, true))
526 {
527 try
528 {
529 char[] output = new char[1024];
530 int read = r.ReadBlock(output, 0, 1024);
531 string trimmedResponse = new string(output, 0, read);
532 logger.Add($"Response was: {trimmedResponse}");
533 if (read == 1024)
534 logger.Add("(Response was trimmed)");
535 }
536 catch (DecoderFallbackException)
537 {
538 // Ignore non-text format
539 }
540 }
541 }
542 }
543 else
544 logger.Add($@"Request to {Url} successfully completed!");
545
546 // if a failure happened on performing the request, there are still situations where we want to process the response.
547 // consider the case of a server returned error code which triggers a WebException, but the server is also returning details on the error in the response.
548 try
549 {
550 if (!wasTimeout)
551 ProcessResponse();
552 }
553 catch (Exception se)
554 {
555 // that said, we don't really care about an error when processing the response if there is already a higher level exception.
556 if (e == null)
557 {
558 logger.Add($"Processing response from {Url} failed with {se}.");
559 Failed?.Invoke(se);
560 Completed = true;
561 Aborted = true;
562 throw;
563 }
564 }
565
566 if (e == null)
567 {
568 Finished?.Invoke();
569 Completed = true;
570 }
571 else
572 {
573 Failed?.Invoke(e);
574 Completed = true;
575 Aborted = true;
576 throw e;
577 }
578 }
579
580 /// <summary>
581 /// Performs any post-processing of the response.
582 /// Exceptions thrown in this method will be passed to <see cref="Failed"/>.
583 /// </summary>
584 protected virtual void ProcessResponse()
585 {
586 }
587
588 /// <summary>
589 /// Forcefully abort the request.
590 /// </summary>
591 public void Abort()
592 {
593 if (Aborted || Completed) return;
594
595 Aborted = true;
596 Completed = true;
597
598 try
599 {
600 abortToken?.Cancel();
601 }
602 catch (ObjectDisposedException)
603 {
604 }
605 }
606
607 /// <summary>
608 /// Adds a raw POST body to this request.
609 /// This may not be used in conjunction with <see cref="AddFile"/> and <see cref="AddParameter(string,string,RequestParameterType)"/>.
610 /// </summary>
611 /// <param name="text">The text.</param>
612 public void AddRaw(string text)
613 {
614 AddRaw(Encoding.UTF8.GetBytes(text));
615 }
616
617 /// <summary>
618 /// Adds a raw POST body to this request.
619 /// This may not be used in conjunction with <see cref="AddFile"/> and <see cref="AddParameter(string,string,RequestParameterType)"/>.
620 /// </summary>
621 /// <param name="bytes">The raw data.</param>
622 public void AddRaw(byte[] bytes)
623 {
624 AddRaw(new MemoryStream(bytes));
625 }
626
627 /// <summary>
628 /// Adds a raw POST body to this request.
629 /// This may not be used in conjunction with <see cref="AddFile"/>
630 /// and <see cref="AddParameter(string,string,RequestParameterType)"/> with the request type of <see cref="RequestParameterType.Form"/>.
631 /// </summary>
632 /// <param name="stream">The stream containing the raw data. This stream will _not_ be finalized by this request.</param>
633 public void AddRaw(Stream stream)
634 {
635 if (stream == null) throw new ArgumentNullException(nameof(stream));
636
637 rawContent ??= new MemoryStream();
638
639 stream.CopyTo(rawContent);
640 }
641
642 /// <summary>
643 /// Add a new FILE parameter to this request. Replaces any existing file with the same name.
644 /// This may not be used in conjunction with <see cref="AddRaw(Stream)"/>. GET requests may not contain files.
645 /// </summary>
646 /// <param name="name">The name of the file. This becomes the name of the file in a multi-part form POST content.</param>
647 /// <param name="data">The file data.</param>
648 public void AddFile(string name, byte[] data)
649 {
650 if (name == null) throw new ArgumentNullException(nameof(name));
651 if (data == null) throw new ArgumentNullException(nameof(data));
652
653 files[name] = data;
654 }
655
656 /// <summary>
657 /// <para>
658 /// Add a new parameter to this request. Replaces any existing parameter with the same name.
659 /// </para>
660 /// <para>
661 /// If this request's <see cref="Method"/> supports a request body (<c>POST, PUT, DELETE, PATCH</c>), a <see cref="RequestParameterType.Form"/> parameter will be added;
662 /// otherwise, a <see cref="RequestParameterType.Query"/> parameter will be added.
663 /// For more fine-grained control over the parameter type, use the <see cref="AddParameter(string,string,RequestParameterType)"/> overload.
664 /// </para>
665 /// <para>
666 /// <see cref="RequestParameterType.Form"/> parameters may not be used in conjunction with <see cref="AddRaw(Stream)"/>.
667 /// </para>
668 /// </summary>
669 /// <remarks>
670 /// Values added to the request URL query string are automatically percent-encoded before sending the request.
671 /// </remarks>
672 /// <param name="name">The name of the parameter.</param>
673 /// <param name="value">The parameter value.</param>
674 public void AddParameter(string name, string value)
675 => AddParameter(name, value, supportsRequestBody(Method) ? RequestParameterType.Form : RequestParameterType.Query);
676
677 /// <summary>
678 /// Add a new parameter to this request. Replaces any existing parameter with the same name.
679 /// <see cref="RequestParameterType.Form"/> parameters may not be used in conjunction with <see cref="AddRaw(Stream)"/>.
680 /// </summary>
681 /// <remarks>
682 /// Values added to the request URL query string are automatically percent-encoded before sending the request.
683 /// </remarks>
684 /// <param name="name">The name of the parameter.</param>
685 /// <param name="value">The parameter value.</param>
686 /// <param name="type">The type of the request parameter.</param>
687 public void AddParameter(string name, string value, RequestParameterType type)
688 {
689 if (name == null) throw new ArgumentNullException(nameof(name));
690 if (value == null) throw new ArgumentNullException(nameof(value));
691
692 switch (type)
693 {
694 case RequestParameterType.Query:
695 queryParameters[name] = value;
696 break;
697
698 case RequestParameterType.Form:
699 if (!supportsRequestBody(Method))
700 throw new ArgumentException("Cannot add form parameter to a request type which has no body.", nameof(type));
701
702 formParameters[name] = value;
703 break;
704 }
705 }
706
707 private static bool supportsRequestBody(HttpMethod method)
708 => method == HttpMethod.Post
709 || method == HttpMethod.Put
710 || method == HttpMethod.Delete
711 || method == HttpMethod.Patch;
712
713 /// <summary>
714 /// Adds a new header to this request. Replaces any existing header with the same name.
715 /// </summary>
716 /// <param name="name">The name of the header.</param>
717 /// <param name="value">The header value.</param>
718 public void AddHeader(string name, string value)
719 {
720 if (name == null) throw new ArgumentNullException(nameof(name));
721 if (value == null) throw new ArgumentNullException(nameof(value));
722
723 headers[name] = value;
724 }
725
726 #region Timeout Handling
727
728 private long lastAction;
729
730 private long timeSinceLastAction => (DateTime.Now.Ticks - lastAction) / TimeSpan.TicksPerMillisecond;
731
732 private void reportForwardProgress()
733 {
734 lastAction = DateTime.Now.Ticks;
735 timeoutToken.CancelAfter(Timeout);
736 }
737
738 #endregion
739
740 #region IDisposable Support
741
742 private bool isDisposed;
743
744 protected void Dispose(bool disposing)
745 {
746 if (isDisposed) return;
747
748 isDisposed = true;
749
750 Abort();
751
752 requestStream?.Dispose();
753 response?.Dispose();
754
755 if (!(ResponseStream is MemoryStream))
756 ResponseStream?.Dispose();
757 }
758
759 public void Dispose()
760 {
761 Dispose(true);
762 GC.SuppressFinalize(this);
763 }
764
765 #endregion
766
767 #region IPv4 fallback implementation
768
769#if NET5_0
770 /// <summary>
771 /// Whether IPv6 should be preferred. Value may change based on runtime failures.
772 /// </summary>
773 private static bool useIPv6 = Socket.OSSupportsIPv6;
774
775 /// <summary>
776 /// Whether the initial IPv6 check has been performed (to determine whether v6 is available or not).
777 /// </summary>
778 private static bool hasResolvedIPv6Availability;
779
780 private const int connection_establish_timeout = 2000;
781
782 private static async ValueTask<Stream> onConnect(SocketsHttpConnectionContext context, CancellationToken cancellationToken)
783 {
784 // Until .NET supports an implementation of Happy Eyeballs (https://tools.ietf.org/html/rfc8305#section-2), let's make IPv4 fallback work in a simple way.
785 // This issue is being tracked at https://github.com/dotnet/runtime/issues/26177 and expected to be fixed in .NET 6.
786
787 if (useIPv6)
788 {
789 try
790 {
791 var localToken = cancellationToken;
792
793 if (!hasResolvedIPv6Availability)
794 {
795 // to make things move fast, use a very low timeout for the initial ipv6 attempt.
796 var quickFailCts = new CancellationTokenSource(connection_establish_timeout);
797 var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, quickFailCts.Token);
798
799 localToken = linkedTokenSource.Token;
800 }
801
802 return await attemptConnection(AddressFamily.InterNetworkV6, context, localToken)
803 .ConfigureAwait(false);
804 }
805 catch
806 {
807 // very naively fallback to ipv4 permanently for this execution based on the response of the first connection attempt.
808 // note that this may cause users to eventually get switched to ipv4 (on a random failure when they are switching networks, for instance)
809 // but in the interest of keeping this implementation simple, this is acceptable.
810 useIPv6 = false;
811 }
812 finally
813 {
814 hasResolvedIPv6Availability = true;
815 }
816 }
817
818 // fallback to IPv4.
819 return await attemptConnection(AddressFamily.InterNetwork, context, cancellationToken).ConfigureAwait(false);
820 }
821
822 private static async ValueTask<Stream> attemptConnection(AddressFamily addressFamily, SocketsHttpConnectionContext context, CancellationToken cancellationToken)
823 {
824 // The following socket constructor will create a dual-mode socket on systems where IPV6 is available.
825 var socket = new Socket(addressFamily, SocketType.Stream, ProtocolType.Tcp)
826 {
827 // Turn off Nagle's algorithm since it degrades performance in most HttpClient scenarios.
828 NoDelay = true
829 };
830
831 try
832 {
833 await socket.ConnectAsync(context.DnsEndPoint, cancellationToken).ConfigureAwait(false);
834 // The stream should take the ownership of the underlying socket,
835 // closing it when it's disposed.
836 return new NetworkStream(socket, ownsSocket: true);
837 }
838 catch
839 {
840 socket.Dispose();
841 throw;
842 }
843 }
844#endif
845
846 #endregion
847
848 private class LengthTrackingStream : Stream
849 {
850 public readonly BindableLong BytesRead = new BindableLong();
851
852 private readonly Stream baseStream;
853
854 public LengthTrackingStream(Stream baseStream)
855 {
856 this.baseStream = baseStream;
857 }
858
859 public override void Flush()
860 {
861 baseStream.Flush();
862 }
863
864 public override int Read(byte[] buffer, int offset, int count)
865 {
866 int read = baseStream.Read(buffer, offset, count);
867 BytesRead.Value += read;
868 return read;
869 }
870
871 public override long Seek(long offset, SeekOrigin origin) => baseStream.Seek(offset, origin);
872
873 public override void SetLength(long value)
874 {
875 baseStream.SetLength(value);
876 }
877
878 public override void Write(byte[] buffer, int offset, int count)
879 {
880 baseStream.Write(buffer, offset, count);
881 }
882
883 public override bool CanRead => baseStream.CanRead;
884 public override bool CanSeek => baseStream.CanSeek;
885 public override bool CanWrite => baseStream.CanWrite;
886 public override long Length => baseStream.Length;
887
888 public override long Position
889 {
890 get => baseStream.Position;
891 set => baseStream.Position = value;
892 }
893
894 protected override void Dispose(bool disposing)
895 {
896 base.Dispose(disposing);
897 baseStream.Dispose();
898 }
899 }
900 }
901}