using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Net.Http; using System.Threading; using System.Threading.Tasks; namespace LaunchDarkly.EventSource { /// /// Provides an EventSource client for consuming Server-Sent Events. Additional details on the Server-Sent Events spec /// can be found at https://html.spec.whatwg.org/multipage/server-sent-events.html /// public class EventSource : IEventSource, IDisposable { #region Private Fields private readonly Configuration _configuration; private readonly HttpClient _httpClient; private List _eventDataStringBuffer; private MemoryStream _eventDataUtf8ByteBuffer; private string _eventName; private string _lastEventId; private readonly ExponentialBackoffWithDecorrelation _backOff; private readonly TimeSpan _configuredInitialRetryDelay; private readonly TimeSpan _configuredMaxRetryDelay; private CancellationTokenSource _currentRequestToken; private readonly CancellationTokenSource _shutdownTokenSource = new CancellationTokenSource(); private static readonly TimeSpan MaxSleepTime = TimeSpan.FromMilliseconds(int.MaxValue); private readonly Stopwatch _connectionTimer = new Stopwatch(); private ReadyState _readyState; #endregion #region Public Events /// public event EventHandler Opened; /// public event EventHandler Closed; /// public event EventHandler MessageReceived; /// public event EventHandler CommentReceived; /// public event EventHandler Error; #endregion Public Events #region Public Properties /// public ReadyState ReadyState { get { lock (this) { return _readyState; } } private set { lock (this) { _readyState = value; } } } internal TimeSpan BackOffDelay { get; private set; } // Exposed for tests, so that retry-bounds behavior can be asserted directly internal ExponentialBackoffWithDecorrelation BackOff => _backOff; #endregion #region Public Constructors /// /// Initializes a new instance of the class. /// /// the configuration public EventSource(Configuration configuration) { _readyState = ReadyState.Raw; _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); _configuredInitialRetryDelay = _configuration.InitialRetryDelay; _configuredMaxRetryDelay = _configuration.MaxRetryDelay; _backOff = new ExponentialBackoffWithDecorrelation(_configuredInitialRetryDelay, _configuredMaxRetryDelay); _httpClient = _configuration.HttpClient ?? CreateHttpClient(); } /// /// Shortcut for initializing an with only a stream URI /// and no custom properties. /// /// the stream URI /// if the URI is null public EventSource(Uri uri) : this(Configuration.Builder(uri).Build()) {} #endregion #region Public Methods /// public async Task StartAsync() { bool firstTime = true; while (ReadyState != ReadyState.Shutdown) { if (!firstTime) { if (_connectionTimer.IsRunning) { if (_connectionTimer.Elapsed >= _configuration.BackoffResetThreshold) { // Sustained healthy operation reverts any temporary bounds and resets // n. Reverting takes precedence over bounds that were // set during the preceding fault window. ClearTemporaryRetryDelayBounds(); _backOff.ResetBackoffN(); } _connectionTimer.Reset(); } await MaybeWaitWithBackOff(); } firstTime = false; CancellationTokenSource newRequestTokenSource = null; CancellationToken cancellationToken; lock (this) { if (_readyState == ReadyState.Shutdown) { // in case Close() was called in between the previous ReadyState check and the creation of the new token return; } newRequestTokenSource = new CancellationTokenSource(); _currentRequestToken?.Dispose(); _currentRequestToken = newRequestTokenSource; } if (ReadyState == ReadyState.Connecting || ReadyState == ReadyState.Open) { throw new InvalidOperationException(string.Format(Resources.ErrorAlreadyStarted, ReadyState)); } SetReadyState(ReadyState.Connecting); cancellationToken = newRequestTokenSource.Token; try { await ConnectToEventSourceAsync(cancellationToken); // ConnectToEventSourceAsync normally doesn't return, unless it detects that the request has been cancelled. Close(ReadyState.Closed); } catch (Exception e) { CancelCurrentRequest(); // If the user called Close(), ReadyState = Shutdown, so errors are irrelevant. if (ReadyState != ReadyState.Shutdown) { Exception realException = e; if (realException is AggregateException ae && ae.InnerExceptions.Count == 1) { realException = ae.InnerException; } if (realException is OperationCanceledException oe) { // This exception could either be the result of us explicitly cancelling a request, in which case we don't // need to do anything else, or it could be that the request timed out. if (oe.CancellationToken.IsCancellationRequested) { realException = null; } else { realException = new TimeoutException(); } } if (realException != null) { OnError(new ExceptionEventArgs(realException)); } Close(ReadyState.Closed); } } } } private async Task MaybeWaitWithBackOff() { TimeSpan sleepTime = _backOff.GetNextBackOff(); if (sleepTime > MaxSleepTime) { // Task.Delay throws for anything above the platform timer ceiling, and this method // runs outside the reconnect loop's exception handling, so an unclamped value // would fault StartAsync and stop the stream permanently. sleepTime = MaxSleepTime; } if (sleepTime > TimeSpan.Zero) { BackOffDelay = sleepTime; try { await Task.Delay(sleepTime, _shutdownTokenSource.Token); } catch (OperationCanceledException) { // Cancellation happened during the wait, likely intentional close } } } /// public void Restart(bool resetBackoffDelay) { lock (this) { if (_readyState != ReadyState.Open) { return; } if (resetBackoffDelay) { _backOff.ResetBackoffN(); } } CancelCurrentRequest(); } /// public void SetTemporaryRetryDelayBounds(TimeSpan initialDelay, TimeSpan maxDelay) { _backOff.SetBounds(initialDelay, maxDelay); } /// public void ClearTemporaryRetryDelayBounds() { _backOff.SetBounds(_configuredInitialRetryDelay, _configuredMaxRetryDelay); } /// /// Closes the connection to the SSE server. The EventSource cannot be reopened after this. /// public void Close() { if (ReadyState != ReadyState.Shutdown) { Close(ReadyState.Shutdown); } CancelCurrentRequest(); _shutdownTokenSource.Cancel(); // do not dispose httpClient if it is user provided if (_configuration.HttpClient == null) { _httpClient.Dispose(); } } /// /// Equivalent to calling . /// public void Dispose() { Dispose(true); } private void Dispose(bool disposing) { if (disposing) { Close(); } } #endregion #region Private Methods private HttpClient CreateHttpClient() { var client =_configuration.HttpMessageHandler is null ? new HttpClient() : new HttpClient(_configuration.HttpMessageHandler, false); client.Timeout = _configuration.ResponseStartTimeout; return client; } private void CancelCurrentRequest() { CancellationTokenSource requestTokenSource = null; lock (this) { requestTokenSource = _currentRequestToken; _currentRequestToken = null; } if (requestTokenSource != null) { requestTokenSource.Cancel(); requestTokenSource.Dispose(); } } internal virtual EventSourceService GetEventSourceService(Configuration configuration) { return new EventSourceService(configuration, _httpClient); } private async Task ConnectToEventSourceAsync(CancellationToken cancellationToken) { _eventDataStringBuffer = null; _eventDataUtf8ByteBuffer = null; var svc = GetEventSourceService(_configuration); svc.ConnectionOpened += (o, e) => { _connectionTimer.Restart(); SetReadyState(ReadyState.Open, OnOpened, e.Headers); }; svc.ConnectionClosed += (o, e) => { SetReadyState(ReadyState.Closed, OnClosed); }; await svc.GetDataAsync( ProcessResponseLineString, ProcessResponseLineUtf8, _lastEventId, cancellationToken ); } private void Close(ReadyState state) { SetReadyState(state, OnClosed); } private void ProcessResponseLineString(string content) { if (content == null) { // StreamReader may emit a null if the stream has been closed; there's nothing to // be done at this level in that case return; } if (content.Length == 0) { DispatchEvent(); } else { HandleParsedLine(EventParser.ParseLineString(content)); } } private void ProcessResponseLineUtf8(Utf8ByteSpan content) { if (content.Length == 0) { DispatchEvent(); } else { HandleParsedLine(EventParser.ParseLineUtf8Bytes(content)); } } private void SetReadyState(ReadyState state, Action action = null, IEnumerable>> headers = null) { lock (this) { if (_readyState == state || _readyState == ReadyState.Shutdown) { return; } _readyState = state; } if (action != null) { action(new StateChangedEventArgs(state, headers)); } } private void HandleParsedLine(EventParser.Result result) { if (result.IsComment) { OnCommentReceived(new CommentReceivedEventArgs(result.GetValueAsString())); } else if (result.IsDataField) { if (result.ValueString != null) { if (_eventDataStringBuffer is null) { _eventDataStringBuffer = new List(2); } _eventDataStringBuffer.Add(result.ValueString); _eventDataStringBuffer.Add("\n"); } else { if (_eventDataUtf8ByteBuffer is null) { _eventDataUtf8ByteBuffer = new MemoryStream(result.ValueBytes.Length + 1); } _eventDataUtf8ByteBuffer.Write(result.ValueBytes.Data, result.ValueBytes.Offset, result.ValueBytes.Length); _eventDataUtf8ByteBuffer.WriteByte((byte)'\n'); } } else if (result.IsEventField) { _eventName = result.GetValueAsString(); } else if (result.IsIdField) { _lastEventId = result.GetValueAsString(); } else if (result.IsRetryField) { if (long.TryParse(result.GetValueAsString(), out var retry)) { // Clamp while the value is still an integer. var millis = Math.Max(0, Math.Min(retry, Constants.MaxServerDirectedRetryDelayMillis)); _backOff.SetServerDirectedMinDelay(TimeSpan.FromMilliseconds(millis)); } } } private void DispatchEvent() { var name = _eventName ?? Constants.MessageField; _eventName = null; MessageEvent message; if (_eventDataStringBuffer != null) { if (_eventDataStringBuffer.Count == 0) { return; } // remove last item which is always a trailing newline _eventDataStringBuffer.RemoveAt(_eventDataStringBuffer.Count - 1); var dataString = string.Concat(_eventDataStringBuffer); message = new MessageEvent(name, dataString, _lastEventId, _configuration.Uri); _eventDataStringBuffer.Clear(); } else { if (_eventDataUtf8ByteBuffer is null || _eventDataUtf8ByteBuffer.Length == 0) { return; } var dataSpan = new Utf8ByteSpan(_eventDataUtf8ByteBuffer.GetBuffer(), 0, (int)_eventDataUtf8ByteBuffer.Length - 1); // remove trailing newline message = new MessageEvent(name, dataSpan, _lastEventId, _configuration.Uri); // We've now taken ownership of the original buffer; null out the previous // reference to it so a new one will be created next time _eventDataUtf8ByteBuffer = null; } OnMessageReceived(new MessageReceivedEventArgs(message)); } private void OnOpened(StateChangedEventArgs e) => Opened?.Invoke(this, e); private void OnClosed(StateChangedEventArgs e) => Closed?.Invoke(this, e); private void OnMessageReceived(MessageReceivedEventArgs e) => MessageReceived?.Invoke(this, e); private void OnCommentReceived(CommentReceivedEventArgs e) => CommentReceived?.Invoke(this, e); private void OnError(ExceptionEventArgs e) => Error?.Invoke(this, e); #endregion } }