When a SPA dev server (or prerendering build) takes too long to start up, only fail current request, not future requests. Fixes #1447

This commit is contained in:
Steve Sanderson
2018-01-02 14:02:10 +00:00
parent 975d537a0a
commit a98c1459b5
7 changed files with 75 additions and 40 deletions

View File

@@ -20,7 +20,6 @@ namespace Microsoft.AspNetCore.SpaServices.AngularCli
public class AngularCliBuilder : ISpaPrerendererBuilder public class AngularCliBuilder : ISpaPrerendererBuilder
{ {
private static TimeSpan RegexMatchTimeout = TimeSpan.FromSeconds(5); // This is a development-time only feature, so a very long timeout is fine private static TimeSpan RegexMatchTimeout = TimeSpan.FromSeconds(5); // This is a development-time only feature, so a very long timeout is fine
private static TimeSpan BuildTimeout = TimeSpan.FromSeconds(50); // Note that the HTTP request itself by default times out after 60s, so you only get useful error information if this is shorter
private readonly string _npmScriptName; private readonly string _npmScriptName;
@@ -63,8 +62,7 @@ namespace Microsoft.AspNetCore.SpaServices.AngularCli
try try
{ {
await npmScriptRunner.StdOut.WaitForMatch( await npmScriptRunner.StdOut.WaitForMatch(
new Regex("Date", RegexOptions.None, RegexMatchTimeout), new Regex("Date", RegexOptions.None, RegexMatchTimeout));
BuildTimeout);
} }
catch (EndOfStreamException ex) catch (EndOfStreamException ex)
{ {

View File

@@ -12,6 +12,7 @@ using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Threading; using System.Threading;
using System.Net.Http; using System.Net.Http;
using Microsoft.AspNetCore.SpaServices.Extensions.Util;
namespace Microsoft.AspNetCore.SpaServices.AngularCli namespace Microsoft.AspNetCore.SpaServices.AngularCli
{ {
@@ -49,7 +50,15 @@ namespace Microsoft.AspNetCore.SpaServices.AngularCli
var targetUriTask = angularCliServerInfoTask.ContinueWith( var targetUriTask = angularCliServerInfoTask.ContinueWith(
task => new UriBuilder("http", "localhost", task.Result.Port).Uri); task => new UriBuilder("http", "localhost", task.Result.Port).Uri);
SpaProxyingExtensions.UseProxyToSpaDevelopmentServer(spaBuilder, targetUriTask); SpaProxyingExtensions.UseProxyToSpaDevelopmentServer(spaBuilder, () =>
{
// On each request, we create a separate startup task with its own timeout. That way, even if
// the first request times out, subsequent requests could still work.
return targetUriTask.WithTimeout(StartupTimeout,
$"The Angular CLI process did not start listening for requests " +
$"within the timeout period of {StartupTimeout.Seconds} seconds. " +
$"Check the log output for error information.");
});
} }
private static async Task<AngularCliServerInfo> StartAngularCliServerAsync( private static async Task<AngularCliServerInfo> StartAngularCliServerAsync(
@@ -68,8 +77,7 @@ namespace Microsoft.AspNetCore.SpaServices.AngularCli
try try
{ {
openBrowserLine = await npmScriptRunner.StdOut.WaitForMatch( openBrowserLine = await npmScriptRunner.StdOut.WaitForMatch(
new Regex("open your browser on (http\\S+)", RegexOptions.None, RegexMatchTimeout), new Regex("open your browser on (http\\S+)", RegexOptions.None, RegexMatchTimeout));
StartupTimeout);
} }
catch (EndOfStreamException ex) catch (EndOfStreamException ex)
{ {
@@ -78,13 +86,6 @@ namespace Microsoft.AspNetCore.SpaServices.AngularCli
$"Angular CLI was listening for requests. The error output was: " + $"Angular CLI was listening for requests. The error output was: " +
$"{stdErrReader.ReadAsString()}", ex); $"{stdErrReader.ReadAsString()}", ex);
} }
catch (TaskCanceledException ex)
{
throw new InvalidOperationException(
$"The Angular CLI process did not start listening for requests " +
$"within the timeout period of {StartupTimeout.Seconds} seconds. " +
$"Check the log output for error information.", ex);
}
} }
var uri = new Uri(openBrowserLine.Groups[1].Value); var uri = new Uri(openBrowserLine.Groups[1].Value);

View File

@@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.NodeServices; using Microsoft.AspNetCore.NodeServices;
using Microsoft.AspNetCore.SpaServices; using Microsoft.AspNetCore.SpaServices;
using Microsoft.AspNetCore.SpaServices.Extensions.Util;
using Microsoft.AspNetCore.SpaServices.Prerendering; using Microsoft.AspNetCore.SpaServices.Prerendering;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Net.Http.Headers; using Microsoft.Net.Http.Headers;
@@ -23,6 +24,8 @@ namespace Microsoft.AspNetCore.Builder
/// </summary> /// </summary>
public static class SpaPrerenderingExtensions public static class SpaPrerenderingExtensions
{ {
private static TimeSpan BuildTimeout = TimeSpan.FromSeconds(50); // Note that the HTTP request itself by default times out after 60s, so you only get useful error information if this is shorter
/// <summary> /// <summary>
/// Enables server-side prerendering middleware for a Single Page Application. /// Enables server-side prerendering middleware for a Single Page Application.
/// </summary> /// </summary>
@@ -85,9 +88,15 @@ namespace Microsoft.AspNetCore.Builder
} }
// If we're building on demand, wait for that to finish, or raise any build errors // If we're building on demand, wait for that to finish, or raise any build errors
if (buildOnDemandTask != null) if (buildOnDemandTask != null && !buildOnDemandTask.IsCompleted)
{ {
await buildOnDemandTask; // For better debuggability, create a per-request timeout that makes it clear if the
// prerendering builder took too long for this request, but without aborting the
// underlying build task so that subsequent requests could still work.
await buildOnDemandTask.WithTimeout(BuildTimeout,
$"The prerendering build process did not complete within the " +
$"timeout period of {BuildTimeout.Seconds} seconds. " +
$"Check the log output for error information.");
} }
// It's no good if we try to return a 304. We need to capture the actual // It's no good if we try to return a 304. We need to capture the actual

View File

@@ -45,7 +45,7 @@ namespace Microsoft.AspNetCore.Builder
{ {
UseProxyToSpaDevelopmentServer( UseProxyToSpaDevelopmentServer(
spaBuilder, spaBuilder,
Task.FromResult(baseUri)); () => Task.FromResult(baseUri));
} }
/// <summary> /// <summary>
@@ -54,10 +54,10 @@ namespace Microsoft.AspNetCore.Builder
/// development. Do not enable this middleware in production applications. /// development. Do not enable this middleware in production applications.
/// </summary> /// </summary>
/// <param name="spaBuilder">The <see cref="ISpaBuilder"/>.</param> /// <param name="spaBuilder">The <see cref="ISpaBuilder"/>.</param>
/// <param name="baseUriTask">A <see cref="Task"/> that resolves with the target base URI to which requests should be proxied.</param> /// <param name="baseUriTaskFactory">A callback that will be invoked on each request to supply a <see cref="Task"/> that resolves with the target base URI to which requests should be proxied.</param>
public static void UseProxyToSpaDevelopmentServer( public static void UseProxyToSpaDevelopmentServer(
this ISpaBuilder spaBuilder, this ISpaBuilder spaBuilder,
Task<Uri> baseUriTask) Func<Task<Uri>> baseUriTaskFactory)
{ {
var applicationBuilder = spaBuilder.ApplicationBuilder; var applicationBuilder = spaBuilder.ApplicationBuilder;
var applicationStoppingToken = GetStoppingToken(applicationBuilder); var applicationStoppingToken = GetStoppingToken(applicationBuilder);
@@ -72,11 +72,11 @@ namespace Microsoft.AspNetCore.Builder
var neverTimeOutHttpClient = var neverTimeOutHttpClient =
SpaProxy.CreateHttpClientForProxy(Timeout.InfiniteTimeSpan); SpaProxy.CreateHttpClientForProxy(Timeout.InfiniteTimeSpan);
// Proxy all requests into the Angular CLI server // Proxy all requests to the SPA development server
applicationBuilder.Use(async (context, next) => applicationBuilder.Use(async (context, next) =>
{ {
var didProxyRequest = await SpaProxy.PerformProxyRequest( var didProxyRequest = await SpaProxy.PerformProxyRequest(
context, neverTimeOutHttpClient, baseUriTask, applicationStoppingToken, context, neverTimeOutHttpClient, baseUriTaskFactory(), applicationStoppingToken,
proxy404s: true); proxy404s: true);
}); });
} }

View File

@@ -11,6 +11,7 @@ using System.IO;
using System.Collections.Generic; using System.Collections.Generic;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.SpaServices.Extensions.Util;
namespace Microsoft.AspNetCore.SpaServices.ReactDevelopmentServer namespace Microsoft.AspNetCore.SpaServices.ReactDevelopmentServer
{ {
@@ -48,7 +49,15 @@ namespace Microsoft.AspNetCore.SpaServices.ReactDevelopmentServer
var targetUriTask = portTask.ContinueWith( var targetUriTask = portTask.ContinueWith(
task => new UriBuilder("http", "localhost", task.Result).Uri); task => new UriBuilder("http", "localhost", task.Result).Uri);
SpaProxyingExtensions.UseProxyToSpaDevelopmentServer(spaBuilder, targetUriTask); SpaProxyingExtensions.UseProxyToSpaDevelopmentServer(spaBuilder, () =>
{
// On each request, we create a separate startup task with its own timeout. That way, even if
// the first request times out, subsequent requests could still work.
return targetUriTask.WithTimeout(StartupTimeout,
$"The create-react-app server did not start listening for requests " +
$"within the timeout period of {StartupTimeout.Seconds} seconds. " +
$"Check the log output for error information.");
});
} }
private static async Task<int> StartCreateReactAppServerAsync( private static async Task<int> StartCreateReactAppServerAsync(
@@ -75,8 +84,7 @@ namespace Microsoft.AspNetCore.SpaServices.ReactDevelopmentServer
// no compiler warnings. So instead of waiting for that, consider it ready as soon // no compiler warnings. So instead of waiting for that, consider it ready as soon
// as it starts listening for requests. // as it starts listening for requests.
await npmScriptRunner.StdOut.WaitForMatch( await npmScriptRunner.StdOut.WaitForMatch(
new Regex("Starting the development server", RegexOptions.None, RegexMatchTimeout), new Regex("Starting the development server", RegexOptions.None, RegexMatchTimeout));
StartupTimeout);
} }
catch (EndOfStreamException ex) catch (EndOfStreamException ex)
{ {
@@ -85,13 +93,6 @@ namespace Microsoft.AspNetCore.SpaServices.ReactDevelopmentServer
$"create-react-app server was listening for requests. The error output was: " + $"create-react-app server was listening for requests. The error output was: " +
$"{stdErrReader.ReadAsString()}", ex); $"{stdErrReader.ReadAsString()}", ex);
} }
catch (TaskCanceledException ex)
{
throw new InvalidOperationException(
$"The create-react-app server did not start listening for requests " +
$"within the timeout period of {StartupTimeout.Seconds} seconds. " +
$"Check the log output for error information.", ex);
}
} }
return portNumber; return portNumber;

View File

@@ -34,7 +34,7 @@ namespace Microsoft.AspNetCore.NodeServices.Util
Task.Factory.StartNew(Run); Task.Factory.StartNew(Run);
} }
public Task<Match> WaitForMatch(Regex regex, TimeSpan timeout = default) public Task<Match> WaitForMatch(Regex regex)
{ {
var tcs = new TaskCompletionSource<Match>(); var tcs = new TaskCompletionSource<Match>();
var completionLock = new object(); var completionLock = new object();
@@ -72,15 +72,6 @@ namespace Microsoft.AspNetCore.NodeServices.Util
OnReceivedLine += onReceivedLineHandler; OnReceivedLine += onReceivedLineHandler;
OnStreamClosed += onStreamClosedHandler; OnStreamClosed += onStreamClosedHandler;
if (timeout != default)
{
var timeoutToken = new CancellationTokenSource(timeout);
timeoutToken.Token.Register(() =>
{
ResolveIfStillPending(() => tcs.SetCanceled());
});
}
return tcs.Task; return tcs.Task;
} }

View File

@@ -0,0 +1,35 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Threading.Tasks;
namespace Microsoft.AspNetCore.SpaServices.Extensions.Util
{
internal static class TaskTimeoutExtensions
{
public static async Task WithTimeout(this Task task, TimeSpan timeoutDelay, string message)
{
if (task == await Task.WhenAny(task, Task.Delay(timeoutDelay)))
{
task.Wait(); // Allow any errors to propagate
}
else
{
throw new TimeoutException(message);
}
}
public static async Task<T> WithTimeout<T>(this Task<T> task, TimeSpan timeoutDelay, string message)
{
if (task == await Task.WhenAny(task, Task.Delay(timeoutDelay)))
{
return task.Result;
}
else
{
throw new TimeoutException(message);
}
}
}
}