Last active
May 4, 2016 21:23
-
-
Save nberardi/ab1b448cc4036122c4e50f7c137e4521 to your computer and use it in GitHub Desktop.
A rethinking of the NSUrlSessionHandler.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using System; | |
using System.Collections.Generic; | |
using System.IO; | |
using System.Linq; | |
using System.Net; | |
using System.Net.Http; | |
using System.Runtime.InteropServices; | |
using System.Threading; | |
using System.Threading.Tasks; | |
#if UNIFIED | |
using CoreFoundation; | |
using Foundation; | |
using Security; | |
#else | |
using MonoTouch.CoreFoundation; | |
using MonoTouch.Foundation; | |
using MonoTouch.Security; | |
using System.Globalization; | |
#endif | |
#if SYSTEM_NET_HTTP | |
namespace System.Net.Http | |
#else | |
namespace Foundation | |
#endif | |
{ | |
public class NSUrlSessionHandler : HttpClientHandler | |
{ | |
private readonly Dictionary<string, string> _headerSeparators = new Dictionary<string, string> | |
{ | |
["User-Agent"] = " ", | |
["Server"] = " " | |
}; | |
private readonly NSUrlSession _session; | |
private readonly Dictionary<NSUrlSessionTask, InflightData> _inflightRequests; | |
private readonly object _inflightRequestsLock = new object(); | |
public NSUrlSessionHandler() | |
{ | |
var configuration = NSUrlSessionConfiguration.DefaultSessionConfiguration; | |
// we cannot do a bitmask but we can set the minimum based on ServicePointManager.SecurityProtocol minimum | |
var sp = ServicePointManager.SecurityProtocol; | |
if ((sp & SecurityProtocolType.Ssl3) != 0) | |
configuration.TLSMinimumSupportedProtocol = SslProtocol.Ssl_3_0; | |
else if ((sp & SecurityProtocolType.Tls) != 0) | |
configuration.TLSMinimumSupportedProtocol = SslProtocol.Tls_1_0; | |
else if ((sp & SecurityProtocolType.Tls11) != 0) | |
configuration.TLSMinimumSupportedProtocol = SslProtocol.Tls_1_1; | |
else if ((sp & SecurityProtocolType.Tls12) != 0) | |
configuration.TLSMinimumSupportedProtocol = SslProtocol.Tls_1_2; | |
_session = NSUrlSession.FromConfiguration(NSUrlSessionConfiguration.DefaultSessionConfiguration, new NSUrlSessionHandlerDelegate(this), null); | |
_inflightRequests = new Dictionary<NSUrlSessionTask, InflightData>(); | |
} | |
protected override void Dispose(bool disposing) | |
{ | |
base.Dispose(disposing); | |
} | |
private string GetHeaderSeparator(string name) | |
{ | |
if (_headerSeparators.ContainsKey(name)) | |
return _headerSeparators[name]; | |
return ","; | |
} | |
private async Task<NSUrlRequest> CreateRequest(HttpRequestMessage request) | |
{ | |
var stream = Stream.Null; | |
var headers = request.Headers as IEnumerable<KeyValuePair<string, IEnumerable<string>>>; | |
if (request.Content != null) | |
{ | |
stream = await request.Content.ReadAsStreamAsync().ConfigureAwait(false); | |
headers = headers.Union(request.Content.Headers).ToArray(); | |
} | |
var nsrequest = new NSMutableUrlRequest | |
{ | |
AllowsCellularAccess = true, | |
CachePolicy = NSUrlRequestCachePolicy.UseProtocolCachePolicy, | |
HttpMethod = request.Method.ToString().ToUpperInvariant(), | |
Url = NSUrl.FromString(request.RequestUri.AbsoluteUri), | |
Headers = headers.Aggregate(new NSMutableDictionary(), (acc, x) => | |
{ | |
acc.Add(new NSString(x.Key), new NSString(string.Join(GetHeaderSeparator(x.Key), x.Value))); | |
return acc; | |
}) | |
}; | |
if (stream != Stream.Null) | |
nsrequest.BodyStream = new WrappedNSInputStream(stream); | |
return nsrequest; | |
} | |
#if SYSTEM_NET_HTTP || MONOMAC | |
internal | |
#endif | |
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) | |
{ | |
var nsrequest = await CreateRequest(request); | |
var dataTask = _session.CreateDataTask(nsrequest); | |
var tcs = new TaskCompletionSource<HttpResponseMessage>(); | |
cancellationToken.Register(() => | |
{ | |
dataTask.Cancel(); | |
InflightData inflight; | |
lock (_inflightRequestsLock) | |
if (_inflightRequests.TryGetValue(dataTask, out inflight)) | |
_inflightRequests.Remove(dataTask); | |
dataTask?.Dispose(); | |
inflight?.Dispose(); | |
tcs.TrySetCanceled(); | |
}); | |
lock (_inflightRequestsLock) | |
_inflightRequests.Add(dataTask, new InflightData | |
{ | |
RequestUrl = request.RequestUri.AbsoluteUri, | |
CompletionSource = tcs, | |
CancellationToken = cancellationToken, | |
Stream = new NSUrlSessionDataTaskStream(), | |
Request = request | |
}); | |
if (dataTask.State == NSUrlSessionTaskState.Suspended) | |
dataTask.Resume(); | |
return await tcs.Task.ConfigureAwait(false); | |
} | |
#if MONOMAC | |
// Needed since we strip during linking since we're inside a product assembly. | |
[Preserve (AllMembers = true)] | |
#endif | |
private class NSUrlSessionHandlerDelegate : NSUrlSessionDataDelegate | |
{ | |
private readonly NSUrlSessionHandler _handler; | |
public NSUrlSessionHandlerDelegate(NSUrlSessionHandler handler) | |
{ | |
_handler = handler; | |
} | |
private InflightData GetInflightData(NSUrlSessionTask task) | |
{ | |
var inflight = default(InflightData); | |
lock (_handler._inflightRequestsLock) | |
if (_handler._inflightRequests.TryGetValue(task, out inflight)) | |
return inflight; | |
return null; | |
} | |
private void RemoveInflightData(NSUrlSessionTask task) | |
{ | |
InflightData inflight; | |
lock (_handler._inflightRequestsLock) | |
if (_handler._inflightRequests.TryGetValue(task, out inflight)) | |
_handler._inflightRequests.Remove(task); | |
task?.Dispose(); | |
inflight?.Dispose(); | |
} | |
public override void DidReceiveResponse(NSUrlSession session, NSUrlSessionDataTask dataTask, NSUrlResponse response, Action<NSUrlSessionResponseDisposition> completionHandler) | |
{ | |
var inflight = GetInflightData(dataTask); | |
try | |
{ | |
var urlResponse = (NSHttpUrlResponse)response; | |
var status = (int)urlResponse.StatusCode; | |
var content = new NSUrlSessionDataTaskStreamContent(inflight.Stream, () => | |
{ | |
dataTask.Cancel(); | |
inflight.Disposed = true; | |
inflight.Stream.TrySetException(new ObjectDisposedException("The content stream was disposed.")); | |
RemoveInflightData(dataTask); | |
}); | |
// NB: The double cast is because of a Xamarin compiler bug | |
var httpResponse = new HttpResponseMessage((HttpStatusCode)status) | |
{ | |
Content = content, | |
RequestMessage = inflight.Request | |
}; | |
httpResponse.RequestMessage.RequestUri = new Uri(urlResponse.Url.AbsoluteString); | |
foreach (var v in urlResponse.AllHeaderFields) | |
{ | |
// NB: Cocoa trolling us so hard by giving us back dummy dictionary entries | |
if (v.Key == null || v.Value == null) continue; | |
httpResponse.Headers.TryAddWithoutValidation(v.Key.ToString(), v.Value.ToString()); | |
httpResponse.Content.Headers.TryAddWithoutValidation(v.Key.ToString(), v.Value.ToString()); | |
} | |
inflight.Response = httpResponse; | |
// We don't want to send the response back to the task just yet. Because we want to mimic .NET behavior | |
// as much as possible. When the response is sent back in .NET, the content stream is ready to read or the | |
// request has completed, because of this we want to send back the response in DidReceiveData or DidCompleteWithError | |
if (dataTask.State == NSUrlSessionTaskState.Suspended) | |
dataTask.Resume(); | |
} | |
catch (Exception ex) | |
{ | |
inflight.CompletionSource.TrySetException(ex); | |
inflight.Stream.TrySetException(ex); | |
dataTask.Cancel(); | |
RemoveInflightData(dataTask); | |
} | |
completionHandler(NSUrlSessionResponseDisposition.Allow); | |
} | |
public override void DidReceiveData(NSUrlSession session, NSUrlSessionDataTask dataTask, NSData data) | |
{ | |
var inflight = GetInflightData(dataTask); | |
inflight.Stream.Add(data); | |
SetResponse(inflight); | |
} | |
public override void DidCompleteWithError(NSUrlSession session, NSUrlSessionTask task, NSError error) | |
{ | |
var inflight = GetInflightData(task); | |
// this can happen if the HTTP request times out and it is removed as part of the cancelation process | |
if (inflight != null) | |
{ | |
// set the stream as finished | |
inflight.Stream.TrySetReceivedAllData(); | |
// send the error or send the response back | |
if (error != null) | |
{ | |
inflight.Errored = true; | |
var exc = CreateExceptionForNSError(error); | |
inflight.CompletionSource.TrySetException(exc); | |
inflight.Stream.TrySetException(exc); | |
} | |
else | |
{ | |
inflight.Completed = true; | |
SetResponse(inflight); | |
} | |
RemoveInflightData(task); | |
} | |
} | |
private void SetResponse(InflightData inflight) | |
{ | |
lock (inflight.Lock) | |
{ | |
if (inflight.ResponseSent) | |
return; | |
if (inflight.CompletionSource.Task.IsCompleted) | |
return; | |
var httpResponse = inflight.Response; | |
inflight.ResponseSent = true; | |
// EVIL HACK: having TrySetResult inline was blocking the request from completing | |
Task.Run(() => inflight.CompletionSource.TrySetResult(httpResponse)); | |
} | |
} | |
public override void DidReceiveChallenge(NSUrlSession session, NSUrlSessionTask task, NSUrlAuthenticationChallenge challenge, Action<NSUrlSessionAuthChallengeDisposition, NSUrlCredential> completionHandler) | |
{ | |
if (challenge.ProtectionSpace.AuthenticationMethod == NSUrlProtectionSpace.AuthenticationMethodNTLM) | |
{ | |
NetworkCredential credentialsToUse; | |
if (_handler.Credentials != null) | |
{ | |
if (_handler.Credentials is NetworkCredential) | |
{ | |
credentialsToUse = (NetworkCredential)_handler.Credentials; | |
} | |
else | |
{ | |
var inflight = GetInflightData(task); | |
var uri = inflight.Request.RequestUri; | |
credentialsToUse = _handler.Credentials.GetCredential(uri, "NTLM"); | |
} | |
var credential = new NSUrlCredential(credentialsToUse.UserName, credentialsToUse.Password, NSUrlCredentialPersistence.ForSession); | |
completionHandler(NSUrlSessionAuthChallengeDisposition.UseCredential, credential); | |
} | |
return; | |
} | |
} | |
public override void WillCacheResponse(NSUrlSession session, NSUrlSessionDataTask dataTask, NSCachedUrlResponse proposedResponse, Action<NSCachedUrlResponse> completionHandler) | |
{ | |
// never cache | |
completionHandler(null); | |
} | |
public override void WillPerformHttpRedirection(NSUrlSession session, NSUrlSessionTask task, NSHttpUrlResponse response, NSUrlRequest newRequest, Action<NSUrlRequest> completionHandler) | |
{ | |
var nextRequest = (_handler.AllowAutoRedirect ? newRequest : null); | |
completionHandler(nextRequest); | |
} | |
private static Exception CreateExceptionForNSError(NSError error) | |
{ | |
var ret = default(Exception); | |
var webExceptionStatus = WebExceptionStatus.UnknownError; | |
var innerException = new NSErrorException(error); | |
if (error.Domain == NSError.NSUrlErrorDomain) | |
{ | |
// Convert the error code into an enumeration (this is future | |
// proof, rather than just casting integer) | |
CFNetworkErrors urlError; | |
if (!Enum.TryParse(error.Code.ToString(), out urlError)) | |
urlError = CFNetworkErrors.Unknown; | |
// Parse the enum into a web exception status or exception. Some | |
// of these values don't necessarily translate completely to | |
// what WebExceptionStatus supports, so made some best guesses | |
// here. For your reading pleasure, compare these: | |
// | |
// Apple docs: https://developer.apple.com/library/mac/documentation/Cocoa/Reference/Foundation/Miscellaneous/Foundation_Constants/index.html#//apple_ref/doc/constant_group/URL_Loading_System_Error_Codes | |
// .NET docs: http://msdn.microsoft.com/en-us/library/system.net.webexceptionstatus(v=vs.110).aspx | |
switch (urlError) | |
{ | |
case CFNetworkErrors.Cancelled: | |
case CFNetworkErrors.UserCancelledAuthentication: | |
// No more processing is required so just return. | |
return new OperationCanceledException(error.LocalizedDescription, innerException); | |
case CFNetworkErrors.BadURL: | |
case CFNetworkErrors.UnsupportedURL: | |
case CFNetworkErrors.CannotConnectToHost: | |
case CFNetworkErrors.ResourceUnavailable: | |
case CFNetworkErrors.NotConnectedToInternet: | |
case CFNetworkErrors.UserAuthenticationRequired: | |
case CFNetworkErrors.InternationalRoamingOff: | |
case CFNetworkErrors.CallIsActive: | |
case CFNetworkErrors.DataNotAllowed: | |
webExceptionStatus = WebExceptionStatus.ConnectFailure; | |
break; | |
case CFNetworkErrors.TimedOut: | |
webExceptionStatus = WebExceptionStatus.Timeout; | |
break; | |
case CFNetworkErrors.CannotFindHost: | |
case CFNetworkErrors.DNSLookupFailed: | |
webExceptionStatus = WebExceptionStatus.NameResolutionFailure; | |
break; | |
case CFNetworkErrors.DataLengthExceedsMaximum: | |
webExceptionStatus = WebExceptionStatus.MessageLengthLimitExceeded; | |
break; | |
case CFNetworkErrors.NetworkConnectionLost: | |
webExceptionStatus = WebExceptionStatus.ConnectionClosed; | |
break; | |
case CFNetworkErrors.HTTPTooManyRedirects: | |
case CFNetworkErrors.RedirectToNonExistentLocation: | |
webExceptionStatus = WebExceptionStatus.ProtocolError; | |
break; | |
case CFNetworkErrors.RequestBodyStreamExhausted: | |
webExceptionStatus = WebExceptionStatus.SendFailure; | |
break; | |
case CFNetworkErrors.BadServerResponse: | |
case CFNetworkErrors.ZeroByteResource: | |
case CFNetworkErrors.CannotDecodeRawData: | |
case CFNetworkErrors.CannotDecodeContentData: | |
case CFNetworkErrors.CannotParseResponse: | |
case CFNetworkErrors.FileDoesNotExist: | |
case CFNetworkErrors.FileIsDirectory: | |
case CFNetworkErrors.NoPermissionsToReadFile: | |
case CFNetworkErrors.CannotLoadFromNetwork: | |
case CFNetworkErrors.CannotCreateFile: | |
case CFNetworkErrors.CannotOpenFile: | |
case CFNetworkErrors.CannotCloseFile: | |
case CFNetworkErrors.CannotWriteToFile: | |
case CFNetworkErrors.CannotRemoveFile: | |
case CFNetworkErrors.CannotMoveFile: | |
case CFNetworkErrors.DownloadDecodingFailedMidStream: | |
case CFNetworkErrors.DownloadDecodingFailedToComplete: | |
webExceptionStatus = WebExceptionStatus.ReceiveFailure; | |
break; | |
case CFNetworkErrors.SecureConnectionFailed: | |
webExceptionStatus = WebExceptionStatus.SecureChannelFailure; | |
break; | |
case CFNetworkErrors.ServerCertificateHasBadDate: | |
case CFNetworkErrors.ServerCertificateHasUnknownRoot: | |
case CFNetworkErrors.ServerCertificateNotYetValid: | |
case CFNetworkErrors.ServerCertificateUntrusted: | |
case CFNetworkErrors.ClientCertificateRejected: | |
case CFNetworkErrors.ClientCertificateRequired: | |
webExceptionStatus = WebExceptionStatus.TrustFailure; | |
break; | |
} | |
} | |
// Always create a WebException so that it can be handled by the client. | |
ret = new WebException(error.LocalizedDescription, innerException, webExceptionStatus, response: null); | |
return ret; | |
} | |
} | |
#if MONOMAC | |
// Needed since we strip during linking since we're inside a product assembly. | |
[Preserve (AllMembers = true)] | |
#endif | |
private class InflightData : IDisposable | |
{ | |
public readonly object Lock = new object(); | |
public string RequestUrl { get; set; } | |
public TaskCompletionSource<HttpResponseMessage> CompletionSource { get; set; } | |
public CancellationToken CancellationToken { get; set; } | |
public NSUrlSessionDataTaskStream Stream { get; set; } | |
public HttpRequestMessage Request { get; set; } | |
public HttpResponseMessage Response { get; set; } | |
public bool ResponseSent { get; set; } | |
public bool Errored { get; set; } | |
public bool Disposed { get; set; } | |
public bool Completed { get; set; } | |
public bool Done { get { return Errored || Disposed || Completed || CancellationToken.IsCancellationRequested; } } | |
public void Dispose() | |
{ | |
Stream?.Dispose(); | |
Request?.Dispose(); | |
Response?.Dispose(); | |
} | |
} | |
#if MONOMAC | |
// Needed since we strip during linking since we're inside a product assembly. | |
[Preserve (AllMembers = true)] | |
#endif | |
private class NSUrlSessionDataTaskStreamContent : StreamContent | |
{ | |
private Action _onDisposed; | |
public NSUrlSessionDataTaskStreamContent(NSUrlSessionDataTaskStream source, Action onDisposed) : base(source) | |
{ | |
_onDisposed = onDisposed; | |
} | |
protected override void Dispose(bool disposing) | |
{ | |
var action = Interlocked.Exchange(ref _onDisposed, null); | |
action?.Invoke(); | |
base.Dispose(disposing); | |
} | |
} | |
#if MONOMAC | |
// Needed since we strip during linking since we're inside a product assembly. | |
[Preserve (AllMembers = true)] | |
#endif | |
private class NSUrlSessionDataTaskStream : Stream | |
{ | |
private readonly Queue<NSData> _data; | |
private readonly object _dataLock = new object(); | |
private long _position; | |
private long _length; | |
private bool _receivedAllData; | |
private Exception _exc; | |
private NSData _current; | |
private Stream _currentStream; | |
public NSUrlSessionDataTaskStream() | |
{ | |
_data = new Queue<NSData>(); | |
} | |
protected override void Dispose(bool disposing) | |
{ | |
foreach (var q in _data) | |
q?.Dispose(); | |
base.Dispose(disposing); | |
} | |
public void Add(NSData data) | |
{ | |
lock (_dataLock) | |
{ | |
_data.Enqueue(data); | |
_length += (int)data.Length; | |
} | |
} | |
public void TrySetReceivedAllData() | |
{ | |
_receivedAllData = true; | |
} | |
public void TrySetException(Exception exc) | |
{ | |
_exc = exc; | |
TrySetReceivedAllData(); | |
} | |
private void ThrowIfNeeded(CancellationToken cancellationToken) | |
{ | |
if (_exc != null) | |
throw _exc; | |
cancellationToken.ThrowIfCancellationRequested(); | |
} | |
public override int Read(byte[] buffer, int offset, int count) | |
{ | |
return ReadAsync(buffer, offset, count).Result; | |
} | |
public override async Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) | |
{ | |
// try to throw on enter | |
ThrowIfNeeded(cancellationToken); | |
while (_current == null) | |
{ | |
lock (_dataLock) | |
{ | |
if (_data.Count == 0 && _receivedAllData && _position == _length) | |
return 0; | |
if (_data.Count > 0 && _current == null) | |
{ | |
_current = _data.Peek(); | |
_currentStream = _current.AsStream(); | |
break; | |
} | |
} | |
await Task.Delay(50); | |
} | |
// try to throw again before read | |
ThrowIfNeeded(cancellationToken); | |
var d = _currentStream; | |
var bufferCount = Math.Min(count, (int)(d.Length - d.Position)); | |
var bytesRead = await d.ReadAsync(buffer, offset, bufferCount, cancellationToken); | |
// add the bytes read from the pointer to the position | |
_position += bytesRead; | |
// remove the current primary reference if the current position has reached the end of the bytes | |
if (d.Position == d.Length) | |
{ | |
lock (_dataLock) | |
{ | |
// this is the same object, it was done to make the cleanup | |
_data.Dequeue(); | |
_current?.Dispose(); | |
_currentStream?.Dispose(); | |
_current = null; | |
_currentStream = null; | |
} | |
} | |
return bytesRead; | |
} | |
public override bool CanRead => true; | |
public override bool CanSeek => false; | |
public override bool CanWrite => false; | |
public override bool CanTimeout => false; | |
public override long Length => _length; | |
public override void SetLength(long value) | |
{ | |
throw new InvalidOperationException(); | |
} | |
public override long Position | |
{ | |
get { return _position; } | |
set { throw new InvalidOperationException(); } | |
} | |
public override long Seek(long offset, SeekOrigin origin) | |
{ | |
throw new InvalidOperationException(); | |
} | |
public override void Flush() | |
{ | |
throw new InvalidOperationException(); | |
} | |
public override void Write(byte[] buffer, int offset, int count) | |
{ | |
throw new InvalidOperationException(); | |
} | |
} | |
#if MONOMAC | |
// Needed since we strip during linking since we're inside a product assembly. | |
[Preserve (AllMembers = true)] | |
#endif | |
private class WrappedNSInputStream : NSInputStream | |
{ | |
private NSStreamStatus _status; | |
private readonly Stream _stream; | |
public WrappedNSInputStream(Stream stream) | |
{ | |
_status = NSStreamStatus.NotOpen; | |
_stream = stream; | |
} | |
public override NSStreamStatus Status | |
{ | |
get | |
{ | |
return _status; | |
} | |
} | |
public override void Open() | |
{ | |
_status = NSStreamStatus.Open; | |
Notify(CFStreamEventType.OpenCompleted); | |
} | |
public override void Close() | |
{ | |
_status = NSStreamStatus.Closed; | |
} | |
public override nint Read(IntPtr buffer, nuint len) | |
{ | |
var source = new byte[len]; | |
var read = _stream.Read(source, 0, (int)len); | |
Marshal.Copy(source, 0, buffer, (int)len); | |
//if (read == 0) | |
// Notify(CFStreamEventType.EndEncountered); | |
return read; | |
} | |
public override bool HasBytesAvailable() | |
{ | |
return true; | |
} | |
protected override bool GetBuffer(out IntPtr buffer, out nuint len) | |
{ | |
// Just call the base implemention (which will return false) | |
return base.GetBuffer(out buffer, out len); | |
} | |
protected override bool SetCFClientFlags(CFStreamEventType inFlags, IntPtr inCallback, IntPtr inContextPtr) | |
{ | |
// Just call the base implementation, which knows how to handle everything. | |
return base.SetCFClientFlags(inFlags, inCallback, inContextPtr); | |
} | |
bool notifying; | |
[Export("_scheduleInCFRunLoop:forMode:")] | |
public void ScheduleInCFRunLoop(CFRunLoop runloop, NSString mode) | |
{ | |
if (notifying) | |
return; | |
notifying = true; | |
Notify(CFStreamEventType.HasBytesAvailable); | |
notifying = false; | |
} | |
[Export("_unscheduleFromCFRunLoop:forMode:")] | |
public void UnscheduleInCFRunLoop(CFRunLoop runloop, NSString mode) | |
{ | |
// Nothing to do here | |
} | |
protected override void Dispose(bool disposing) | |
{ | |
_stream?.Dispose(); | |
} | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment