Added support for AssemblyMetadata caching during build server TagHelper discovery

This commit is contained in:
Ajay Bhargav Baaskaran 2018-02-20 18:50:53 -08:00
parent 00485d9f1b
commit a9dca60a10
15 changed files with 599 additions and 10 deletions

View File

@ -6,17 +6,19 @@ using System.Collections.Generic;
using System.IO;
using System.Reflection;
using System.Threading;
using Microsoft.CodeAnalysis;
using Microsoft.Extensions.CommandLineUtils;
namespace Microsoft.AspNetCore.Razor.Tools
{
internal class Application : CommandLineApplication
{
public Application(CancellationToken cancellationToken, ExtensionAssemblyLoader loader, ExtensionDependencyChecker checker)
public Application(CancellationToken cancellationToken, ExtensionAssemblyLoader loader, ExtensionDependencyChecker checker, Func<string, MetadataReferenceProperties, PortableExecutableReference> assemblyReferenceProvider)
{
CancellationToken = cancellationToken;
Checker = checker;
Loader = loader;
AssemblyReferenceProvider = assemblyReferenceProvider;
Name = "rzc";
FullName = "Microsoft ASP.NET Core Razor CLI tool";
@ -37,6 +39,8 @@ namespace Microsoft.AspNetCore.Razor.Tools
public ExtensionDependencyChecker Checker { get; }
public Func<string, MetadataReferenceProperties, PortableExecutableReference> AssemblyReferenceProvider { get; }
public new int Execute(params string[] args)
{
try

View File

@ -0,0 +1,32 @@
// 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 Microsoft.CodeAnalysis;
namespace Microsoft.AspNetCore.Razor.Tools
{
internal sealed class CachingMetadataReference : PortableExecutableReference
{
private static readonly MetadataCache _metadataCache = new MetadataCache();
public CachingMetadataReference(string fullPath, MetadataReferenceProperties properties)
: base(properties, fullPath)
{
}
protected override DocumentationProvider CreateDocumentationProvider()
{
return DocumentationProvider.Default;
}
protected override Metadata GetMetadataImpl()
{
return _metadataCache.GetMetadata(FilePath);
}
protected override PortableExecutableReference WithPropertiesImpl(MetadataReferenceProperties properties)
{
return new CachingMetadataReference(FilePath, properties);
}
}
}

View File

@ -1,10 +1,12 @@
// 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.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using Microsoft.CodeAnalysis;
namespace Microsoft.AspNetCore.Razor.Tools
{
@ -30,8 +32,12 @@ namespace Microsoft.AspNetCore.Razor.Tools
// consistently reject a request that doesn't specify everything it needs. Otherwise the request
// could succeed sometimes if it relies on transient state.
Loader = new DefaultExtensionAssemblyLoader(Path.Combine(Path.GetTempPath(), "Razor-Server"));
AssemblyReferenceProvider = (path, properties) => new CachingMetadataReference(path, properties);
}
public Func<string, MetadataReferenceProperties, PortableExecutableReference> AssemblyReferenceProvider { get; }
public ExtensionAssemblyLoader Loader { get; }
public override ServerResponse Execute(ServerRequest request, CancellationToken cancellationToken)
@ -48,7 +54,7 @@ namespace Microsoft.AspNetCore.Razor.Tools
var writer = ServerLogger.IsLoggingEnabled ? new StringWriter() : TextWriter.Null;
var checker = new DefaultExtensionDependencyChecker(Loader, writer);
var app = new Application(cancellationToken, Loader, checker)
var app = new Application(cancellationToken, Loader, checker, AssemblyReferenceProvider)
{
Out = writer,
Error = writer,

View File

@ -0,0 +1,207 @@
// 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.Collections.Generic;
using System.Diagnostics;
namespace Microsoft.AspNetCore.Razor.Tools
{
/// <summary>
/// Cache with a fixed size that evicts the least recently used members.
/// Thread-safe.
/// This was taken from https://github.com/dotnet/roslyn/blob/749c0ec135d7d080658dc1aa794d15229c3d10d2/src/Compilers/Core/Portable/InternalUtilities/ConcurrentLruCache.cs.
/// </summary>
internal class ConcurrentLruCache<TKey, TValue>
{
private readonly int _capacity;
private readonly Dictionary<TKey, CacheValue> _cache;
private readonly LinkedList<TKey> _nodeList;
// This is a naive course-grained lock, it can probably be optimized
private readonly object _lockObject = new object();
public ConcurrentLruCache(int capacity)
: this (capacity, EqualityComparer<TKey>.Default)
{
}
public ConcurrentLruCache(int capacity, IEqualityComparer<TKey> comparer)
{
if (capacity <= 0)
{
throw new ArgumentOutOfRangeException(nameof(capacity));
}
_capacity = capacity;
_cache = new Dictionary<TKey, CacheValue>(capacity, comparer);
_nodeList = new LinkedList<TKey>();
}
/// <summary>
/// Create cache from an array. The cache capacity will be the size
/// of the array. All elements of the array will be added to the
/// cache. If any duplicate keys are found in the array a
/// <see cref="ArgumentException"/> will be thrown.
/// </summary>
public ConcurrentLruCache(KeyValuePair<TKey, TValue>[] array)
: this(array.Length)
{
foreach (var kvp in array)
{
UnsafeAdd(kvp.Key, kvp.Value);
}
}
public int Count
{
get
{
lock (_lockObject)
{
return _cache.Count;
}
}
}
public void Add(TKey key, TValue value)
{
lock (_lockObject)
{
UnsafeAdd(key, value);
}
}
public TValue GetOrAdd(TKey key, TValue value)
{
lock (_lockObject)
{
if (UnsafeTryGetValue(key, out var result))
{
return result;
}
else
{
UnsafeAdd(key, value);
return value;
}
}
}
public bool TryGetValue(TKey key, out TValue value)
{
lock (_lockObject)
{
return UnsafeTryGetValue(key, out value);
}
}
public bool Remove(TKey key)
{
lock (_lockObject)
{
return UnsafeRemove(key);
}
}
/// <summary>
/// For testing. Very expensive.
/// </summary>
internal IEnumerable<KeyValuePair<TKey, TValue>> TestingEnumerable
{
get
{
lock (_lockObject)
{
foreach (var key in _nodeList)
{
var kvp = new KeyValuePair<TKey, TValue>(key, _cache[key].Value);
yield return kvp;
}
}
}
}
/// <summary>
/// Doesn't lock.
/// </summary>
private bool UnsafeTryGetValue(TKey key, out TValue value)
{
if (_cache.TryGetValue(key, out var result))
{
MoveNodeToTop(result.Node);
value = result.Value;
return true;
}
else
{
value = default(TValue);
return false;
}
}
private void MoveNodeToTop(LinkedListNode<TKey> node)
{
if (!object.ReferenceEquals(_nodeList.First, node))
{
_nodeList.Remove(node);
_nodeList.AddFirst(node);
}
}
/// <summary>
/// Expects non-empty cache. Does not lock.
/// </summary>
private void UnsafeEvictLastNode()
{
Debug.Assert(_capacity > 0);
var lastNode = _nodeList.Last;
_nodeList.Remove(lastNode);
_cache.Remove(lastNode.Value);
}
private void UnsafeAddNodeToTop(TKey key, TValue value)
{
var node = new LinkedListNode<TKey>(key);
_cache.Add(key, new CacheValue(value, node));
_nodeList.AddFirst(node);
}
/// <summary>
/// Doesn't lock.
/// </summary>
private void UnsafeAdd(TKey key, TValue value)
{
if (_cache.TryGetValue(key, out var result))
{
throw new ArgumentException("Key already exists", nameof(key));
}
else
{
if (_cache.Count == _capacity)
{
UnsafeEvictLastNode();
}
UnsafeAddNodeToTop(key, value);
}
}
private bool UnsafeRemove(TKey key)
{
_nodeList.Remove(key);
return _cache.Remove(key);
}
private struct CacheValue
{
public CacheValue(TValue value, LinkedListNode<TKey> node)
{
Value = value;
Node = node;
}
public TValue Value { get; }
public LinkedListNode<TKey> Node { get; }
}
}
}

View File

@ -133,7 +133,7 @@ namespace Microsoft.AspNetCore.Razor.Tools
var metadataReferences = new MetadataReference[assemblies.Length];
for (var i = 0; i < assemblies.Length; i++)
{
metadataReferences[i] = MetadataReference.CreateFromFile(assemblies[i]);
metadataReferences[i] = Parent.AssemblyReferenceProvider(assemblies[i], default(MetadataReferenceProperties));
}
var engine = RazorProjectEngine.Create(configuration, RazorProjectFileSystem.Empty, b =>

View File

@ -0,0 +1,86 @@
// 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.Diagnostics;
using System.IO;
using System.Reflection.PortableExecutable;
using Microsoft.CodeAnalysis;
namespace Microsoft.AspNetCore.Razor.Tools
{
internal class MetadataCache
{
// Store 1000 entries -- arbitrary number
private const int CacheSize = 1000;
private readonly ConcurrentLruCache<string, MetadataCacheEntry> _metadataCache =
new ConcurrentLruCache<string, MetadataCacheEntry>(CacheSize, StringComparer.OrdinalIgnoreCase);
// For testing purposes only.
internal ConcurrentLruCache<string, MetadataCacheEntry> Cache => _metadataCache;
internal Metadata GetMetadata(string fullPath)
{
var timestamp = GetFileTimeStamp(fullPath);
// Check if we have an entry in the dictionary.
if (_metadataCache.TryGetValue(fullPath, out var entry))
{
if (timestamp.HasValue && timestamp.Value == entry.Timestamp)
{
// The file has not changed since we cached it. Return the cached entry.
return entry.Metadata;
}
else
{
// The file has changed recently. Remove the cache entry.
_metadataCache.Remove(fullPath);
}
}
Metadata metadata;
using (var fileStream = File.OpenRead(fullPath))
{
metadata = AssemblyMetadata.CreateFromStream(fileStream, PEStreamOptions.PrefetchMetadata);
}
_metadataCache.GetOrAdd(fullPath, new MetadataCacheEntry(timestamp.Value, metadata));
return metadata;
}
private static DateTime? GetFileTimeStamp(string fullPath)
{
try
{
Debug.Assert(Path.IsPathRooted(fullPath));
return File.GetLastWriteTimeUtc(fullPath);
}
catch (Exception e)
{
// There are several exceptions that can occur here: NotSupportedException or PathTooLongException
// for a bad path, UnauthorizedAccessException for access denied, etc. Rather than listing them all,
// just catch all exceptions and log.
ServerLogger.LogException(e, $"Error getting timestamp of file {fullPath}.");
return null;
}
}
internal struct MetadataCacheEntry
{
public MetadataCacheEntry(DateTime timestamp, Metadata metadata)
{
Debug.Assert(timestamp.Kind == DateTimeKind.Utc);
Timestamp = timestamp;
Metadata = metadata;
}
public DateTime Timestamp { get; }
public Metadata Metadata { get; }
}
}
}

View File

@ -3,6 +3,7 @@
using System;
using System.Threading;
using Microsoft.CodeAnalysis;
namespace Microsoft.AspNetCore.Razor.Tools
{
@ -19,7 +20,12 @@ namespace Microsoft.AspNetCore.Razor.Tools
var loader = new DefaultExtensionAssemblyLoader(baseDirectory: null);
var checker = new DefaultExtensionDependencyChecker(loader, Console.Error);
var application = new Application(cancel.Token, loader, checker);
var application = new Application(
cancel.Token,
loader,
checker,
(path, properties) => MetadataReference.CreateFromFile(path, properties));
return application.Execute(args);
}
}

View File

@ -57,6 +57,7 @@ namespace Microsoft.AspNetCore.Razor.Tools
}
var host = ConnectionHost.Create(Pipe.Value());
var compilerHost = CompilerHost.Create();
ExecuteServerCore(host, compilerHost, Cancelled, eventBus: null, keepAlive: keepAlive);
}

View File

@ -170,7 +170,7 @@ namespace Microsoft.AspNetCore.Razor.Tools
/// <summary>
/// Write a Request to the stream.
/// </summary>
public async Task WriteAsync(Stream outStream, CancellationToken cancellationToken = default)
public async Task WriteAsync(Stream outStream, CancellationToken cancellationToken = default(CancellationToken))
{
using (var memoryStream = new MemoryStream())
using (var writer = new BinaryWriter(memoryStream, Encoding.Unicode))

View File

@ -1,7 +1,9 @@
// 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.IO;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Testing.xunit;
using Xunit;
namespace Microsoft.AspNetCore.Razor.Design.IntegrationTests
@ -20,6 +22,12 @@ namespace Microsoft.AspNetCore.Razor.Design.IntegrationTests
public Task Build_SimpleMvc_WithServer_UsingDotnetMSBuild_CanBuildSuccessfully()
=> Build_SimpleMvc_CanBuildSuccessfully(MSBuildProcessKind.Dotnet);
[ConditionalFact]
[OSSkipCondition(OperatingSystems.Linux | OperatingSystems.MacOSX)]
[InitializeTestProject("SimpleMvc")]
public Task Build_SimpleMvc_WithServer_UsingDesktopMSBuild_CanBuildSuccessfully()
=> Build_SimpleMvc_CanBuildSuccessfully(MSBuildProcessKind.Desktop);
private async Task Build_SimpleMvc_CanBuildSuccessfully(MSBuildProcessKind msBuildProcessKind)
{
var result = await DotnetMSBuild(
@ -32,6 +40,17 @@ namespace Microsoft.AspNetCore.Razor.Design.IntegrationTests
Assert.FileExists(result, OutputPath, "SimpleMvc.pdb");
Assert.FileExists(result, OutputPath, "SimpleMvc.Views.dll");
Assert.FileExists(result, OutputPath, "SimpleMvc.Views.pdb");
// Verify RazorTagHelper works
Assert.FileExists(result, IntermediateOutputPath, "SimpleMvc.TagHelpers.input.cache");
Assert.FileExists(result, IntermediateOutputPath, "SimpleMvc.TagHelpers.output.cache");
Assert.FileContains(
result,
Path.Combine(IntermediateOutputPath, "SimpleMvc.TagHelpers.output.cache"),
@"""Name"":""SimpleMvc.SimpleTagHelper""");
// Verify RazorGenerate works
Assert.FileCountEquals(result, 8, RazorIntermediateOutputPath, "*.cs");
}
[Fact]

View File

@ -5,6 +5,7 @@ using System;
using System.IO;
using System.Threading;
using Microsoft.AspNetCore.Razor.Tools;
using Microsoft.CodeAnalysis;
using Moq;
namespace Microsoft.AspNetCore.Razor.Design.IntegrationTests
@ -35,7 +36,7 @@ namespace Microsoft.AspNetCore.Razor.Design.IntegrationTests
throw new TimeoutException($"Shutting down the build server at pipe {PipeName} took longer than expected.");
});
var application = new Application(cts.Token, Mock.Of<ExtensionAssemblyLoader>(), Mock.Of<ExtensionDependencyChecker>());
var application = new Application(cts.Token, Mock.Of<ExtensionAssemblyLoader>(), Mock.Of<ExtensionDependencyChecker>(), (path, properties) => Mock.Of<PortableExecutableReference>());
var exitCode = application.Execute("shutdown", "-w", "-p", PipeName);
if (exitCode != 0)
{

View File

@ -0,0 +1,120 @@
// 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.Collections.Generic;
using System.Linq;
using Xunit;
namespace Microsoft.AspNetCore.Razor.Tools
{
public class ConcurrentLruCacheTest
{
[Fact]
public void ConcurrentLruCache_HoldsCapacity()
{
// Arrange
var input = GetKeyValueArray(Enumerable.Range(1, 3));
var expected = input.Reverse();
// Act
var cache = new ConcurrentLruCache<int, int>(input);
// Assert
Assert.Equal(expected, cache.TestingEnumerable);
}
[Fact]
public void Add_ThrowsIfKeyExists()
{
// Arrange
var input = GetKeyValueArray(Enumerable.Range(1, 3));
var cache = new ConcurrentLruCache<int, int>(input);
// Act & Assert
var exception = Assert.Throws<ArgumentException>(() => cache.Add(1, 1));
Assert.StartsWith("Key already exists", exception.Message);
}
[Fact]
public void GetOrAdd_AddsIfKeyDoesNotExist()
{
// Arrange
var input = GetKeyValueArray(Enumerable.Range(1, 3));
var expected = GetKeyValueArray(Enumerable.Range(2, 3)).Reverse();
var cache = new ConcurrentLruCache<int, int>(input);
// Act
cache.GetOrAdd(4, 4);
// Assert
Assert.Equal(expected, cache.TestingEnumerable);
}
[Fact]
public void Remove_RemovesEntry()
{
// Arrange
var input = GetKeyValueArray(Enumerable.Range(1, 3));
var expected = GetKeyValueArray(Enumerable.Range(1, 2)).Reverse();
var cache = new ConcurrentLruCache<int, int>(input);
// Act
var result = cache.Remove(3);
// Assert
Assert.True(result);
Assert.Equal(expected, cache.TestingEnumerable);
}
[Fact]
public void Remove_KeyNotFound_ReturnsFalse()
{
// Arrange
var input = GetKeyValueArray(Enumerable.Range(1, 3));
var cache = new ConcurrentLruCache<int, int>(input);
// Act
var result = cache.Remove(4);
// Assert
Assert.False(result);
}
[Fact]
public void Add_NoRead_EvictsLastNode()
{
// Arrange
var input = GetKeyValueArray(Enumerable.Range(1, 3));
var expected = GetKeyValueArray(Enumerable.Range(2, 3)).Reverse();
var cache = new ConcurrentLruCache<int, int>(input);
// Act
cache.Add(4, 4);
// Assert
Assert.Equal(expected, cache.TestingEnumerable);
}
[Fact]
public void Add_ReadLastNode_EvictsSecondOldestNode()
{
// Arrange
var input = GetKeyValueArray(Enumerable.Range(1, 3));
var expected = GetKeyValueArray(new int[] { 4, 1, 3 });
var cache = new ConcurrentLruCache<int, int>(input);
// Act
cache.GetOrAdd(1, 1); // Read to make this MRU
cache.Add(4, 4); // Add a new node
// Assert
Assert.Equal(expected, cache.TestingEnumerable);
}
private KeyValuePair<int, int>[] GetKeyValueArray(IEnumerable<int> inputArray)
{
return inputArray.Select(v => new KeyValuePair<int, int>(v, v)).ToArray();
}
}
}

View File

@ -6,6 +6,7 @@ using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
using Moq;
namespace Microsoft.AspNetCore.Razor.Tools
@ -117,7 +118,7 @@ namespace Microsoft.AspNetCore.Razor.Tools
CancellationToken ct,
EventBus eventBus,
TimeSpan? keepAlive)
: base(new Application(ct, Mock.Of<ExtensionAssemblyLoader>(), Mock.Of<ExtensionDependencyChecker>()))
: base(new Application(ct, Mock.Of<ExtensionAssemblyLoader>(), Mock.Of<ExtensionDependencyChecker>(), (path, properties) => Mock.Of<PortableExecutableReference>()))
{
_host = host;
_compilerHost = compilerHost;

View File

@ -0,0 +1,103 @@
// 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.IO;
using Xunit;
namespace Microsoft.AspNetCore.Razor.Tools
{
public class MetadataCacheTest
{
[Fact]
public void GetMetadata_AddsToCache()
{
using (var directory = TempDirectory.Create())
{
// Arrange
var metadataCache = new MetadataCache();
var assemblyFilePath = LoaderTestResources.Delta.WriteToFile(directory.DirectoryPath, "Delta.dll");
// Act
var result = metadataCache.GetMetadata(assemblyFilePath);
// Assert
Assert.NotNull(result);
Assert.Equal(1, metadataCache.Cache.Count);
}
}
[Fact]
public void GetMetadata_UsesCache()
{
using (var directory = TempDirectory.Create())
{
// Arrange
var metadataCache = new MetadataCache();
var assemblyFilePath = LoaderTestResources.Delta.WriteToFile(directory.DirectoryPath, "Delta.dll");
// Act 1
var result = metadataCache.GetMetadata(assemblyFilePath);
// Assert 1
Assert.NotNull(result);
Assert.Equal(1, metadataCache.Cache.Count);
// Act 2
var cacheResult = metadataCache.GetMetadata(assemblyFilePath);
// Assert 2
Assert.Same(result, cacheResult);
Assert.Equal(1, metadataCache.Cache.Count);
}
}
[Fact]
public void GetMetadata_MultipleFiles_ReturnsDifferentResultsAndAddsToCache()
{
using (var directory = TempDirectory.Create())
{
// Arrange
var metadataCache = new MetadataCache();
var assemblyFilePath1 = LoaderTestResources.Delta.WriteToFile(directory.DirectoryPath, "Delta.dll");
var assemblyFilePath2 = LoaderTestResources.Gamma.WriteToFile(directory.DirectoryPath, "Gamma.dll");
// Act
var result1 = metadataCache.GetMetadata(assemblyFilePath1);
var result2 = metadataCache.GetMetadata(assemblyFilePath2);
// Assert
Assert.NotSame(result1, result2);
Assert.Equal(2, metadataCache.Cache.Count);
}
}
[Fact]
public void GetMetadata_ReplacesCache_IfFileTimestampChanged()
{
using (var directory = TempDirectory.Create())
{
// Arrange
var metadataCache = new MetadataCache();
var assemblyFilePath = LoaderTestResources.Delta.WriteToFile(directory.DirectoryPath, "Delta.dll");
// Act 1
var result = metadataCache.GetMetadata(assemblyFilePath);
// Assert 1
Assert.NotNull(result);
var entry = Assert.Single(metadataCache.Cache.TestingEnumerable);
Assert.Same(result, entry.Value.Metadata);
// Act 2
// Update the timestamp of the file
File.SetLastWriteTimeUtc(assemblyFilePath, File.GetLastWriteTimeUtc(assemblyFilePath).AddSeconds(1));
var cacheResult = metadataCache.GetMetadata(assemblyFilePath);
// Assert 2
Assert.NotSame(result, cacheResult);
entry = Assert.Single(metadataCache.Cache.TestingEnumerable);
Assert.Same(cacheResult, entry.Value.Metadata);
}
}
}
}

View File

@ -116,7 +116,8 @@ namespace Microsoft.AspNetCore.Razor.Tools
/// A shutdown request should not abort an existing compilation. It should be allowed to run to
/// completion.
/// </summary>
[Fact(Skip = "Skipping temporarily on non-windows. https://github.com/aspnet/Razor/issues/1991")]
[ConditionalFact(Skip = "Skipping temporarily on non-windows. https://github.com/aspnet/Razor/issues/1991")]
[OSSkipCondition(OperatingSystems.Linux | OperatingSystems.MacOSX)]
public async Task ServerRunning_ShutdownRequest_DoesNotAbortCompilation()
{
// Arrange
@ -152,7 +153,8 @@ namespace Microsoft.AspNetCore.Razor.Tools
/// <summary>
/// Multiple clients should be able to send shutdown requests to the server.
/// </summary>
[Fact(Skip = "Skipping temporarily on non-windows. https://github.com/aspnet/Razor/issues/1991")]
[ConditionalFact(Skip = "Skipping temporarily on non-windows. https://github.com/aspnet/Razor/issues/1991")]
[OSSkipCondition(OperatingSystems.Linux | OperatingSystems.MacOSX)]
public async Task ServerRunning_MultipleShutdownRequests_HandlesSuccessfully()
{
// Arrange
@ -189,7 +191,8 @@ namespace Microsoft.AspNetCore.Razor.Tools
}
}
[Fact(Skip = "Skipping temporarily on non-windows. https://github.com/aspnet/Razor/issues/1991")]
[ConditionalFact(Skip = "Skipping temporarily on non-windows. https://github.com/aspnet/Razor/issues/1991")]
[OSSkipCondition(OperatingSystems.Linux | OperatingSystems.MacOSX)]
public async Task ServerRunning_CancelCompilation_CancelsSuccessfully()
{
// Arrange