diff --git a/src/BloomExe/BloomExe.csproj b/src/BloomExe/BloomExe.csproj index b2cef97d42ea..0752bab9d8d4 100644 --- a/src/BloomExe/BloomExe.csproj +++ b/src/BloomExe/BloomExe.csproj @@ -185,6 +185,7 @@ copy /Y "$(SolutionDir)\lib\dotnet\libtidy.dll" $(OutDir) + @@ -351,4 +352,4 @@ copy /Y "$(SolutionDir)\lib\dotnet\libtidy.dll" $(OutDir) - \ No newline at end of file + diff --git a/src/BloomExe/web/BloomHttpListenerContext.cs b/src/BloomExe/web/BloomHttpListenerContext.cs index 95fef614dd57..fa8a661b991d 100644 --- a/src/BloomExe/web/BloomHttpListenerContext.cs +++ b/src/BloomExe/web/BloomHttpListenerContext.cs @@ -1,24 +1,26 @@ -using System.Net; +using System.IO; +using System.Net; +using EmbedIO; namespace Bloom.web { /// /// At this point, the only point of these classes is so we can write tests without having - /// to try to spin up a real HttpListener which causes problems on TeamCity. + /// to try to spin up a real HTTP server which causes problems on TeamCity. /// - /// The Bloom... concrete classes are simply wrappers for the real objects. + /// The Bloom... concrete classes are wrappers for EmbedIO's IHttpContext. /// public interface IHttpListenerContext { IHttpListenerRequest Request { get; } - HttpListenerResponse Response { get; } + IHttpListenerResponse Response { get; } } public class BloomHttpListenerContext : IHttpListenerContext { - private readonly HttpListenerContext _actualContext; + private readonly IHttpContext _actualContext; - public BloomHttpListenerContext(HttpListenerContext context) + public BloomHttpListenerContext(IHttpContext context) { _actualContext = context; } @@ -28,9 +30,9 @@ public IHttpListenerRequest Request get { return new BloomHttpListenerRequest(_actualContext.Request); } } - public HttpListenerResponse Response + public IHttpListenerResponse Response { - get { return _actualContext.Response; } + get { return new BloomHttpListenerResponse(_actualContext.Response); } } } @@ -47,9 +49,9 @@ public interface IHttpListenerRequest public class BloomHttpListenerRequest : IHttpListenerRequest { - private readonly HttpListenerRequest _actualRequest; + private readonly IHttpRequest _actualRequest; - public BloomHttpListenerRequest(HttpListenerRequest request) + public BloomHttpListenerRequest(IHttpRequest request) { _actualRequest = request; } @@ -71,7 +73,7 @@ public bool HasEntityBody public string HttpMethod { - get { return _actualRequest.HttpMethod; } + get { return _actualRequest.HttpVerb.ToString(); } } public System.IO.Stream InputStream @@ -89,4 +91,79 @@ public System.Uri Url get { return _actualRequest.Url; } } } + + public interface IHttpListenerResponse + { + string ContentType { get; set; } + long ContentLength64 { get; set; } + int StatusCode { get; set; } + string StatusDescription { get; set; } + Stream OutputStream { get; } + void AppendHeader(string name, string value); + void Close(byte[] buffer, bool willBlock); + void Close(); + } + + public class BloomHttpListenerResponse : IHttpListenerResponse + { + private readonly IHttpResponse _actualResponse; + private long _contentLength; + + public BloomHttpListenerResponse(IHttpResponse response) + { + _actualResponse = response; + } + + public string ContentType + { + get { return _actualResponse.ContentType; } + set { _actualResponse.ContentType = value; } + } + + public long ContentLength64 + { + get { return _contentLength; } + set + { + _contentLength = value; + _actualResponse.ContentLength64 = value; + } + } + + public int StatusCode + { + get { return _actualResponse.StatusCode; } + set { _actualResponse.StatusCode = value; } + } + + public string StatusDescription + { + get { return _actualResponse.StatusDescription; } + set { _actualResponse.StatusDescription = value; } + } + + public Stream OutputStream + { + get { return _actualResponse.OutputStream; } + } + + public void AppendHeader(string name, string value) + { + _actualResponse.Headers.Add(name, value); + } + + public void Close(byte[] buffer, bool willBlock) + { + if (buffer != null && buffer.Length > 0) + { + _actualResponse.OutputStream.Write(buffer, 0, buffer.Length); + } + _actualResponse.Close(); + } + + public void Close() + { + _actualResponse.Close(); + } + } } diff --git a/src/BloomExe/web/BloomServer.cs b/src/BloomExe/web/BloomServer.cs index e16ab366cac0..f1d43cf78dc3 100644 --- a/src/BloomExe/web/BloomServer.cs +++ b/src/BloomExe/web/BloomServer.cs @@ -28,6 +28,9 @@ using Bloom.web; using Bloom.web.controllers; using DesktopAnalytics; +using EmbedIO; +using EmbedIO.Routing; +using EmbedIO.WebApi; using L10NSharp; using SIL.Code; using SIL.IO; @@ -81,14 +84,14 @@ public static string ServerUrlWithBloomPrefixEndingInSlash } /// - /// Listens for requests" + /// EmbedIO web server that listens for requests /// - private HttpListener _listener; + private WebServer _webServer; /// - /// Requests that come into the _listener are placed in the _queue so they can be processed + /// Requests that come into the server are placed in the _queue so they can be processed /// - private readonly Queue _queue; + private readonly ConcurrentQueue _queue; // tasks that should be postponed until no server actions are happening. @@ -186,7 +189,7 @@ public BloomServer( BloomFileLocator fileLocator = null ) { - _queue = new Queue(); + _queue = new ConcurrentQueue(); _stop = new ManualResetEvent(false); _ready = new ManualResetEvent(false); _listenerThread = new Thread(EnqueueIncomingRequests); @@ -1192,7 +1195,7 @@ private static bool IsSimulatedFileUrl(string localPath) /// /// /// - protected bool IsRecursiveRequestContext(HttpListenerContext context) + protected bool IsRecursiveRequestContext(IHttpContext context) { return context.Request.QueryString["generateThumbnailIfNecessary"] == "true"; } @@ -1287,7 +1290,7 @@ private bool ProcessCssFile(IRequestInfo info, string incomingPath) /// public virtual void EnsureListening() { - if (_listener?.IsListening != true) + if (_webServer == null || _webServer.State != WebServerState.Listening) StartListening(); } @@ -1344,12 +1347,10 @@ private bool AttemptToOpenPort() Logger.WriteMinorEvent( "Attempting to start http listener on " + ServerUrlEndingInSlash ); - _listener = new HttpListener - { - AuthenticationSchemes = AuthenticationSchemes.Anonymous - }; - _listener.Prefixes.Add(ServerUrlEndingInSlash); - _listener.Start(); + _webServer = new WebServer( + o => o.WithUrlPrefix(ServerUrlEndingInSlash).WithMode(HttpListenerMode.EmbedIO) + ).WithModule(new BloomServerModule("/", this)); + _webServer.Start(); return true; } catch (HttpListenerException error) @@ -1385,10 +1386,9 @@ private bool HandleExceptionOpeningPort(Exception error) ); try { - if (_listener != null) + if (_webServer != null) { - //_listener.Stop(); this will always throw if we failed to start, so skip it and go to the close: - _listener.Close(); + _webServer.Dispose(); } } catch (Exception) @@ -1397,11 +1397,30 @@ private bool HandleExceptionOpeningPort(Exception error) } finally { - _listener = null; + _webServer = null; } return false; } + /// + /// Called by BloomServerModule to handle incoming requests + /// + public async Task HandleRequestAsync(IHttpContext context) + { + // Use a TaskCompletionSource to bridge between EmbedIO's async model and our queue-based threading model + var tcs = new TaskCompletionSource(); + + // Store the TCS with the context so we can signal completion later + context.Items["CompletionSource"] = tcs; + + // Add request to queue + _queue.Enqueue(context); + _ready.Set(); + + // Wait for processing to complete + await tcs.Task; + } + public static bool ServerIsListening { get; internal set; } private static void VerifyWeAreNowListening() @@ -1498,11 +1517,12 @@ private void SpinUpAWorker() #endregion /// - /// The _listenerThread runs this method, and exits when the _stop event is raised + /// The _listenerThread runs this method, and exits when the _stop event is raised. + /// With EmbedIO, this just monitors for dead worker threads. /// private void EnqueueIncomingRequests() { - while (_listener.IsListening) + while (!_stop.WaitOne(1000)) { // We've found that sometimes one of our worker threads just dies. One way to force it to happen is to // uncomment the block of code that converts requests for .map files into 404s. @@ -1527,36 +1547,29 @@ private void EnqueueIncomingRequests() _workers.TryRemove(kvp.Key, out Thread _); // Seems like just making one would be enough, but preliminary testing still found the number // declining slowly. - while (_workers.Count < MinWorkerThreads) - SpinUpAWorker(); + lock (_queue) + { + while (_workers.Count < MinWorkerThreads) + SpinUpAWorker(); + } } - - var context = _listener.BeginGetContext(QueueRequest, null); - - if (0 == WaitHandle.WaitAny(new[] { _stop, context.AsyncWaitHandle })) - return; } } /// - /// This method is called in the _listenerThread when we obtain an HTTP request from - /// the _listener, and queues it for processing by a worker. + /// No longer needed with EmbedIO - requests are queued directly by HandleRequestAsync. + /// This method is kept for backward compatibility but does nothing. /// /// private void QueueRequest(IAsyncResult ar) { - // this can happen when shutting down - // BL-2207 indicates it may be possible for the thread to be alive and the listener closed, - // although the only way I know it gets closed happens after joining with that thread. - // Still, one more check seems worthwhile...if we're far enough along in shutting down - // to have closed the listener we certainly can't respond to any more requests. - if (!_listenerThread.IsAlive || !_listener.IsListening) - return; + // No longer used with EmbedIO + } + private void CheckForBlockedWorkers() + { lock (_queue) { - _queue.Enqueue(_listener.EndGetContext(ar)); - // Deal with a situation where all the workers are blocked, // but there is a request in the queue that would unblock the current workers // but that request can't run because it's stuck in queue @@ -1588,20 +1601,18 @@ private void RequestProcessorLoop() // _ready in the wait array) if a request is ready, and 1 when _stop is signaled, breaking us out of the loop. while (WaitHandle.WaitAny(wait) == 0) { - HttpListenerContext context; - bool isRecursiveRequestContext; // needs to be declared outside the lock but initialized afte we have the context. - lock (_queue) + IHttpContext context; + bool isRecursiveRequestContext; + + // Try to dequeue a request + if (!_queue.TryDequeue(out context)) { - if (_queue.Count > 0) - { - context = _queue.Dequeue(); - } - else - { - _ready.Reset(); - continue; - } + _ready.Reset(); + continue; + } + lock (_queue) + { isRecursiveRequestContext = IsRecursiveRequestContext(context); if (isRecursiveRequestContext) { @@ -1618,8 +1629,15 @@ private void RequestProcessorLoop() } var rawurl = "unknown"; + TaskCompletionSource tcs = null; try { + // Get the TaskCompletionSource if it exists + if (context.Items.TryGetValue("CompletionSource", out var tcsObj)) + { + tcs = tcsObj as TaskCompletionSource; + } + rawurl = context.Request.RawUrl; // Enhance: the DAISY ACE accessibility report points at images in the epub, correctly and raw, like "tiger.png" @@ -1636,9 +1654,8 @@ private void RequestProcessorLoop() ) { var r = new RequestInfo(new BloomHttpListenerContext(context)); - r.WriteError(404); - + tcs?.SetResult(true); return; } // Uncommenting this is a way to cause lots of worker threads to die when an inspector is opened. @@ -1657,6 +1674,9 @@ private void RequestProcessorLoop() Thread.CurrentThread.Priority = ThreadPriority.BelowNormal; MakeReply(new RequestInfo(new BloomHttpListenerContext(context))); + + // Signal completion + tcs?.SetResult(true); } catch (HttpListenerException e) { @@ -1666,6 +1686,7 @@ private void RequestProcessorLoop() + e.Message ); Logger.WriteEvent("At BloomServer: ListenerCallback(): url=" + rawurl); + tcs?.SetException(e); } catch (Exception error) { @@ -1700,6 +1721,7 @@ error is IOException Debug.Fail("(Debug Only) " + error.Message); #endif } + tcs?.SetException(error); } finally { @@ -2155,7 +2177,7 @@ protected virtual void Dispose(bool fDisposing) try { ServerIsListening = false; - if (_listener != null) + if (_webServer != null) { //prompted by the mysterious BL 273, Crash while closing down the imageserver Guard.AgainstNull(_listenerThread, "_listenerThread"); @@ -2198,19 +2220,9 @@ var kvp in _workers.Where( } } - // stop listening for incoming http requests - Debug.Assert(_listener.IsListening); - if (_listener.IsListening) - { - //In BL-3290, a user quitely failed here each time he exited Bloom, with a Cannot access a disposed object. - //according to http://stackoverflow.com/questions/11164919/why-httplistener-start-method-dispose-stuff-on-exception, - //it's actually just responding to being closed, not disposed. - //I don't know *why* for that user the listener was already stopped. - _listener.Stop(); - } - //if we keep getting that exception, we could move the Close() into the previous block - _listener.Close(); - _listener = null; + // stop the EmbedIO web server + _webServer.Dispose(); + _webServer = null; } if (_cache != null) { diff --git a/src/BloomExe/web/BloomServerModule.cs b/src/BloomExe/web/BloomServerModule.cs new file mode 100644 index 000000000000..379df5e82bd1 --- /dev/null +++ b/src/BloomExe/web/BloomServerModule.cs @@ -0,0 +1,30 @@ +// Copyright (c) 2014-2018 SIL International +// This software is licensed under the MIT License (http://opensource.org/licenses/MIT) +using System; +using System.Threading.Tasks; +using EmbedIO; + +namespace Bloom.Api +{ + /// + /// Custom EmbedIO module that forwards all requests to BloomServer's queue-based processing system + /// + public class BloomServerModule : WebModuleBase + { + private readonly BloomServer _bloomServer; + + public BloomServerModule(string baseRoute, BloomServer bloomServer) + : base(baseRoute) + { + _bloomServer = bloomServer; + } + + public override bool IsFinalHandler => true; + + protected override async Task OnRequestAsync(IHttpContext context) + { + // Queue the request for processing by BloomServer's worker threads + await _bloomServer.HandleRequestAsync(context); + } + } +} diff --git a/src/BloomExe/web/RequestInfo.cs b/src/BloomExe/web/RequestInfo.cs index d1bd732137e1..99fcbf472e79 100644 --- a/src/BloomExe/web/RequestInfo.cs +++ b/src/BloomExe/web/RequestInfo.cs @@ -86,7 +86,7 @@ public void WriteCompleteOutput(string s) WriteOutput(Encoding.UTF8.GetBytes(s), _actualContext.Response); } - private void WriteOutput(byte[] buffer, HttpListenerResponse response) + private void WriteOutput(byte[] buffer, IHttpListenerResponse response) { response.ContentLength64 += buffer.Length; // This is particularly useful in allowing the bloom-player used in the BloomPUB preview @@ -105,6 +105,13 @@ private void WriteOutput(byte[] buffer, HttpListenerResponse response) { ReportHttpListenerProblem(e); } + catch (IOException e) + { + // EmbedIO may throw IOException when the connection is closed prematurely + Logger.WriteEvent( + "Could not write requested data: connection closed. " + e.Message + ); + } HaveOutput = true; } diff --git a/src/BloomTests/web/RequestInfoTests.cs b/src/BloomTests/web/RequestInfoTests.cs index 82fb26d72d6b..bc211bbccd14 100644 --- a/src/BloomTests/web/RequestInfoTests.cs +++ b/src/BloomTests/web/RequestInfoTests.cs @@ -88,7 +88,7 @@ private TempFile MakeTempFile(byte[] contents) private class TestHttpListenerContext : IHttpListenerContext { public IHttpListenerRequest Request { get; private set; } - public HttpListenerResponse Response { get; private set; } + public IHttpListenerResponse Response { get; private set; } public void SetRequest(IHttpListenerRequest request) {