Skip to content

[Foundation] Separate the cancellation tokens from the current inflight data. fixes #11799 #15678

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 12 commits into
base: d17-3
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,414 changes: 0 additions & 1,414 deletions src/Foundation/NSUrlSessionHandler.cs

This file was deleted.

201 changes: 201 additions & 0 deletions src/Foundation/NSUrlSessionHandler/InflightData.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;

using Foundation;

#nullable enable

#if !MONOMAC
namespace System.Net.Http {
#else
namespace Foundation {
#endif

class NSUrlSessionHandlerInflightData : IDisposable {
readonly NSUrlSessionHandler sessionHandler;
readonly Dictionary<NSUrlSessionTask, (InflightData data, CancellationData cancellation)> inflightRequests = new ();

public object Lock { get; } = new object ();

public NSUrlSessionHandlerInflightData (NSUrlSessionHandler handler) {
sessionHandler = handler;
}

public void CancelAll () {
// the cancelation task of each of the sources will clean the different resources. Each removal is done
// inside a lock, but of course, the .Values collection will not like that because it is modified during the
// iteration. We split the operation in two, get all the diff cancelation sources, then try to cancel each of them
// which will do the correct lock dance. Note that we could be tempted to do a RemoveAll, that will yield the same
// runtime issue, this is dull but safe.
List<TaskCompletionSource<HttpResponseMessage>> sources;
lock (Lock) { // just lock when we iterate
sources = new (inflightRequests.Count);
foreach (var (_, cancellation) in inflightRequests.Values) {
sources.Add (cancellation.CompletionSource);
}
}
sources.ForEach (source => { source.TrySetCanceled (); });
}

public (InflightData inflight, CancellationData cancellation) Create (NSUrlSessionTask dataTask, string requestUrl, HttpRequestMessage request, CancellationToken cancellationToken) {
var inflightData = new InflightData (request.RequestUri?.AbsoluteUri!, request);
var cancellationData = new CancellationData (cancellationToken);

lock (Lock) {
#if !MONOMAC && !__WATCHOS__
// Add the notification whenever needed
sessionHandler.AddNotification ();
#endif
inflightRequests.Add (dataTask, new (inflightData, cancellationData));
}

// as per documentation:
// If this token is already in the canceled state, the
// delegate will be run immediately and synchronously.
// Any exception the delegate generates will be
// propagated out of this method call.
//
// The execution of the register ensures that if we
// receive a already cancelled token or it is cancelled
// just before this call, we will cancel the task.
// Other approaches are harder, since querying the state
// of the token does not guarantee that in the next
// execution a threads cancels it.
cancellationToken.Register (() => {
Remove (dataTask);
cancellationData.CompletionSource.TrySetCanceled ();
});

return (inflightData, cancellationData);
}

public void Get (NSUrlSessionTask task, out InflightData? inflightData, out CancellationData? cancellationData)
{
lock (Lock) {
if (inflightRequests.TryGetValue (task, out var inflight)) {
// ensure that we did not cancel the request, if we did, do cancel the task, if we
// cancel the task it means that we are not interested in any of the delegate methods:
//
// DidReceiveResponse We might have received a response, but either the user cancelled or a
// timeout did, if that is the case, we do not care about the response.
// DidReceiveData Of buffer has a partial response ergo garbage and there is not real
// reason we would like to add more data.
// DidCompleteWithError - We are not changing a behaviour compared to the case in which
// we did not find the data.
(inflightData, cancellationData) = inflight;
if (cancellationData.CancellationToken.IsCancellationRequested) {
task?.Cancel ();
// return null so that we break out of any delegate method, but we do have the cancellation data
inflightData = null;
}
} else {
// if we did not manage to get the inflight data, we either got an error or have been canceled,
// lets cancel the task, that will execute DidCompleteWithError
task?.Cancel ();
inflightData = null;
cancellationData = null;
}
}
}

public void Remove (NSUrlSessionTask task, bool cancel = true)
{
lock (Lock) {
if (inflightRequests.TryGetValue (task, out var inflight)) {
var (inflightData, cancellationData) = inflight;
if (cancel)
cancellationData.CancellationTokenSource.Cancel ();
cancellationData.Dispose ();
inflightRequests.Remove (task);
}
#if !MONOMAC && !__WATCHOS__
// do we need to be notified? If we have not inflightData, we do not
if (inflightRequests.Count == 0)
sessionHandler.RemoveNotification ();
#endif
if (cancel)
task?.Cancel ();

task?.Dispose ();
}

}

protected void Dispose (bool disposing)
{
lock (Lock) {
#if !MONOMAC && !__WATCHOS__
// remove the notification if present, method checks against null
sessionHandler.RemoveNotification ();
#endif
foreach (var pair in inflightRequests) {
pair.Key?.Cancel ();
pair.Key?.Dispose ();
var (_, cancellation) = pair.Value;
cancellation.Dispose ();
}

inflightRequests.Clear ();
}
}

public void Dispose()
{
Dispose (true);
GC.SuppressFinalize(this);
}

}

class CancellationData : IDisposable {
public TaskCompletionSource<HttpResponseMessage> CompletionSource { get; } = new TaskCompletionSource<HttpResponseMessage> (TaskCreationOptions.RunContinuationsAsynchronously);
public CancellationToken CancellationToken { get; set; }
public CancellationTokenSource CancellationTokenSource { get; } = new CancellationTokenSource ();

public CancellationData (CancellationToken cancellationToken) {
CancellationToken = cancellationToken;
}

public void Dispose()
{
Dispose (true);
GC.SuppressFinalize(this);
}

protected virtual void Dispose (bool disposing)
{
if (disposing) {
CancellationTokenSource.Dispose ();
}
}
}

// Contains the data of the infligh requests. Should not contain any reference to the cancellation objects, thos
// are shared by the managed code and we want to make sure that we have a clear separation between the managed and the
// unmanaged worlds.
class InflightData
{
public readonly object Lock = new object ();
public string RequestUrl { get; set; }

public NSUrlSessionDataTaskStream Stream { get; } = new NSUrlSessionDataTaskStream ();
public HttpRequestMessage Request { get; set; }
public HttpResponseMessage? Response { get; set; }

public Exception? Exception { get; set; }
public bool ResponseSent { get; set; }
public bool Errored { get; set; }
public bool Disposed { get; set; }
public bool Completed { get; set; }

public InflightData (string requestUrl, HttpRequestMessage request)
{
RequestUrl = requestUrl;
Request = request;
}
}

}
111 changes: 111 additions & 0 deletions src/Foundation/NSUrlSessionHandler/MonoStreamContent.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
//
// Copied from https://github.com/mono/mono/blob/2019-02/mcs/class/System.Net.Http/System.Net.Http/StreamContent.cs.
//
// This is not a perfect solution, but the most robust and risk-free approach.
//
// The implementation depends on Mono-specific behavior, which makes SerializeToStreamAsync() cancellable.
// Unfortunately, the CoreFX implementation of HttpClient does not support this.
//
// By copying Mono's old implementation here, we ensure that we're compatible with both HttpClient implementations,
// so when we eventually adopt the CoreFX version in all of Mono's profiles, we don't regress here.
//
using System;
using System.Net;
using System.Net.Http;
using System.IO;
using System.Threading;
using System.Threading.Tasks;

#nullable enable

#if !MONOMAC
namespace System.Net.Http {
#else
namespace Foundation {
#endif

class MonoStreamContent : HttpContent
{
readonly Stream content;
readonly int bufferSize;
readonly CancellationToken cancellationToken;
readonly long startPosition;
bool contentCopied;

public MonoStreamContent (Stream content)
: this (content, 16 * 1024)
{
}

public MonoStreamContent (Stream content, int bufferSize)
{
if (content is null)
ObjCRuntime.ThrowHelper.ThrowArgumentNullException (nameof (content));

if (bufferSize <= 0)
ObjCRuntime.ThrowHelper.ThrowArgumentOutOfRangeException (nameof (bufferSize), bufferSize, "Buffer size must be >0");

this.content = content;
this.bufferSize = bufferSize;

if (content.CanSeek) {
startPosition = content.Position;
}
}

//
// Workarounds for poor .NET API
// Instead of having SerializeToStreamAsync with CancellationToken as public API. Only LoadIntoBufferAsync
// called internally from the send worker can be cancelled and user cannot see/do it
//
internal MonoStreamContent (Stream content, CancellationToken cancellationToken)
: this (content)
{
// We don't own the token so don't worry about disposing it
this.cancellationToken = cancellationToken;
}

protected override Task<Stream> CreateContentReadStreamAsync ()
{
return Task.FromResult (content);
}

protected override void Dispose (bool disposing)
{
if (disposing) {
content.Dispose ();
}

base.Dispose (disposing);
}

protected override Task SerializeToStreamAsync (Stream stream, TransportContext? context)
{
if (contentCopied) {
if (!content.CanSeek) {
throw new InvalidOperationException ("The stream was already consumed. It cannot be read again.");
}

content.Seek (startPosition, SeekOrigin.Begin);
} else {
contentCopied = true;
}

return content.CopyToAsync (stream, bufferSize, cancellationToken);
}

#if !NET
internal
#endif
protected override bool TryComputeLength (out long length)
{
if (!content.CanSeek) {
length = 0;
return false;
}
length = content.Length - startPosition;
return true;
}
}

}
74 changes: 74 additions & 0 deletions src/Foundation/NSUrlSessionHandler/NSHttpCookieExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
using System;
using System.Globalization;
using System.Text;
using System.Net.Http;
using System.Net.Http.Headers;

using Foundation;

#if !MONOMAC
using UIKit;
#endif

#nullable enable

// useful extensions for the class in order to set it in a header used by the NSUrlSessionHandler
// for cookie management.
#if !MONOMAC
namespace System.Net.Http {
#else
namespace Foundation {
#endif

static class NSHttpCookieExtensions
{
static void AppendSegment (StringBuilder builder, string name, string? value)
{
if (builder.Length > 0)
builder.Append ("; ");

builder.Append (name);
if (value is not null)
builder.Append ("=").Append (value);
}

// returns the header for a cookie
public static string GetHeaderValue (this NSHttpCookie cookie)
{
var header = new StringBuilder();
AppendSegment (header, cookie.Name, cookie.Value);
AppendSegment (header, NSHttpCookie.KeyPath.ToString (), cookie.Path.ToString ());
AppendSegment (header, NSHttpCookie.KeyDomain.ToString (), cookie.Domain.ToString ());
AppendSegment (header, NSHttpCookie.KeyVersion.ToString (), cookie.Version.ToString ());

if (cookie.Comment is not null)
AppendSegment (header, NSHttpCookie.KeyComment.ToString (), cookie.Comment.ToString());

if (cookie.CommentUrl is not null)
AppendSegment (header, NSHttpCookie.KeyCommentUrl.ToString (), cookie.CommentUrl.ToString());

if (cookie.Properties.ContainsKey (NSHttpCookie.KeyDiscard))
AppendSegment (header, NSHttpCookie.KeyDiscard.ToString (), null);

if (cookie.ExpiresDate is not null) {
// Format according to RFC1123; 'r' uses invariant info (DateTimeFormatInfo.InvariantInfo)
var dateStr = ((DateTime) cookie.ExpiresDate).ToUniversalTime ().ToString("r", CultureInfo.InvariantCulture);
AppendSegment (header, NSHttpCookie.KeyExpires.ToString (), dateStr);
}

if (cookie.Properties.ContainsKey (NSHttpCookie.KeyMaximumAge)) {
var timeStampString = (NSString) cookie.Properties[NSHttpCookie.KeyMaximumAge];
AppendSegment (header, NSHttpCookie.KeyMaximumAge.ToString (), timeStampString);
}

if (cookie.IsSecure)
AppendSegment (header, NSHttpCookie.KeySecure.ToString(), null);

if (cookie.IsHttpOnly)
AppendSegment (header, "httponly", null); // Apple does not show the key for the httponly

return header.ToString ();
}
}

}
Loading