A game framework written with osu! in mind.
at master 34 kB view raw
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}