diff --git a/Razor.sln b/Razor.sln index e814dd63cf..f32eb14ffb 100644 --- a/Razor.sln +++ b/Razor.sln @@ -1,6 +1,6 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio 15 -VisualStudioVersion = 15.0.27107.3000 +VisualStudioVersion = 15.0.27130.2010 MinimumVisualStudioVersion = 15.0.26730.03 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{3C0D6505-79B3-49D0-B4C3-176F0F1836ED}" ProjectSection(SolutionItems) = preProject @@ -92,6 +92,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Razor. EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "benchmarks", "benchmarks", "{C2C98051-0F39-47F2-80B6-E72B29159F2C}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Razor.Tools", "src\Microsoft.AspNetCore.Razor.Tools\Microsoft.AspNetCore.Razor.Tools.csproj", "{3E7F2D49-3B45-45A8-9893-F73EC1EEBAAB}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -378,6 +380,14 @@ Global {6205467F-E381-4C42-AEEC-763BD62B3D5E}.Release|Any CPU.Build.0 = Release|Any CPU {6205467F-E381-4C42-AEEC-763BD62B3D5E}.ReleaseNoVSIX|Any CPU.ActiveCfg = Release|Any CPU {6205467F-E381-4C42-AEEC-763BD62B3D5E}.ReleaseNoVSIX|Any CPU.Build.0 = Release|Any CPU + {3E7F2D49-3B45-45A8-9893-F73EC1EEBAAB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3E7F2D49-3B45-45A8-9893-F73EC1EEBAAB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3E7F2D49-3B45-45A8-9893-F73EC1EEBAAB}.DebugNoVSIX|Any CPU.ActiveCfg = Debug|Any CPU + {3E7F2D49-3B45-45A8-9893-F73EC1EEBAAB}.DebugNoVSIX|Any CPU.Build.0 = Debug|Any CPU + {3E7F2D49-3B45-45A8-9893-F73EC1EEBAAB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3E7F2D49-3B45-45A8-9893-F73EC1EEBAAB}.Release|Any CPU.Build.0 = Release|Any CPU + {3E7F2D49-3B45-45A8-9893-F73EC1EEBAAB}.ReleaseNoVSIX|Any CPU.ActiveCfg = Release|Any CPU + {3E7F2D49-3B45-45A8-9893-F73EC1EEBAAB}.ReleaseNoVSIX|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -418,6 +428,7 @@ Global {AFD77E2F-1A4A-4C2C-9EA9-7E48C8926780} = {3C0D6505-79B3-49D0-B4C3-176F0F1836ED} {323553F0-14AB-4FBD-9CF0-1CC0BE8056F8} = {92463391-81BE-462B-AC3C-78C6C760741F} {6205467F-E381-4C42-AEEC-763BD62B3D5E} = {C2C98051-0F39-47F2-80B6-E72B29159F2C} + {3E7F2D49-3B45-45A8-9893-F73EC1EEBAAB} = {3C0D6505-79B3-49D0-B4C3-176F0F1836ED} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {0035341D-175A-4D05-95E6-F1C2785A1E26} diff --git a/src/Microsoft.AspNetCore.Razor.Tools/Application.cs b/src/Microsoft.AspNetCore.Razor.Tools/Application.cs new file mode 100644 index 0000000000..cbf995a3f2 --- /dev/null +++ b/src/Microsoft.AspNetCore.Razor.Tools/Application.cs @@ -0,0 +1,91 @@ +// 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.Reflection; +using System.Threading; +using Microsoft.Extensions.CommandLineUtils; + +namespace Microsoft.AspNetCore.Razor.TagHelperTool +{ + internal class Application : CommandLineApplication + { + public Application(CancellationToken cancellationToken) + { + CancellationToken = cancellationToken; + + Name = "rzc"; + FullName = "Microsoft ASP.NET Core Razor CLI tool"; + Description = "CLI interface to perform Razor operations."; + ShortVersionGetter = GetInformationalVersion; + + HelpOption("-?|-h|--help"); + + Commands.Add(new DiscoverCommand(this)); + Commands.Add(new ServerCommand(this)); + } + + public CancellationToken CancellationToken { get; } + + public new int Execute(params string[] args) + { + try + { + return base.Execute(ExpandResponseFiles(args)); + } + catch (AggregateException ex) when (ex.InnerException != null) + { + Error.WriteLine(ex.InnerException.Message); + Error.WriteLine(ex.InnerException.StackTrace); + return 1; + } + catch (CommandParsingException ex) + { + // Don't show a call stack when we have unneeded arguments, just print the error message. + // The code that throws this exception will print help, so no need to do it here. + Error.WriteLine(ex.Message); + return 1; + } + catch (OperationCanceledException) + { + // This is a cancellation, not a failure. + Error.WriteLine("Cancelled"); + return 1; + } + catch (Exception ex) + { + Error.WriteLine(ex.Message); + Error.WriteLine(ex.StackTrace); + return 1; + } + } + + private string GetInformationalVersion() + { + var assembly = typeof(Application).GetTypeInfo().Assembly; + var attribute = assembly.GetCustomAttribute(); + return attribute.InformationalVersion; + } + + private static string[] ExpandResponseFiles(string[] args) + { + var expandedArgs = new List(); + foreach (var arg in args) + { + if (!arg.StartsWith("@", StringComparison.Ordinal)) + { + expandedArgs.Add(arg); + } + else + { + var fileName = arg.Substring(1); + expandedArgs.AddRange(File.ReadLines(fileName)); + } + } + + return expandedArgs.ToArray(); + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Razor.Tools/Client.cs b/src/Microsoft.AspNetCore.Razor.Tools/Client.cs new file mode 100644 index 0000000000..12dfcd6fd6 --- /dev/null +++ b/src/Microsoft.AspNetCore.Razor.Tools/Client.cs @@ -0,0 +1,89 @@ +// 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.IO; +using System.IO.Pipes; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.CommandLine; + +namespace Microsoft.AspNetCore.Razor.TagHelperTool +{ + internal abstract class Client : IDisposable + { + // Based on: https://github.com/dotnet/roslyn/blob/14aed138a01c448143b9acf0fe77a662e3dfe2f4/src/Compilers/Shared/BuildServerConnection.cs#L290 + public static async Task ConnectAsync(string pipeName, TimeSpan? timeout, CancellationToken cancellationToken) + { + var timeoutMilliseconds = timeout == null ? Timeout.Infinite : (int)timeout.Value.TotalMilliseconds; + + try + { + // Machine-local named pipes are named "\\.\pipe\". + // We use the SHA1 of the directory the compiler exes live in as the pipe name. + // The NamedPipeClientStream class handles the "\\.\pipe\" part for us. + CompilerServerLogger.Log("Attempt to open named pipe '{0}'", pipeName); + + var stream = new NamedPipeClientStream(".", pipeName, PipeDirection.InOut, PipeOptions.Asynchronous); + cancellationToken.ThrowIfCancellationRequested(); + + CompilerServerLogger.Log("Attempt to connect named pipe '{0}'", pipeName); + try + { + await stream.ConnectAsync(timeoutMilliseconds, cancellationToken); + } + catch (Exception e) when (e is IOException || e is TimeoutException) + { + // Note: IOException can also indicate timeout. + // From docs: + // - TimeoutException: Could not connect to the server within the specified timeout period. + // - IOException: The server is connected to another client and the time-out period has expired. + CompilerServerLogger.Log($"Connecting to server timed out after {timeoutMilliseconds} ms"); + return null; + } + + CompilerServerLogger.Log("Named pipe '{0}' connected", pipeName); + cancellationToken.ThrowIfCancellationRequested(); + + // The original code in Roslyn checks that the server pipe is owned by the same user for security. + // We plan to rely on the BCL for this but it's not yet implemented: + // See https://github.com/dotnet/corefx/issues/25427 + + return new NamedPipeClient(stream); + } + catch (Exception e) when (!(e is TaskCanceledException || e is OperationCanceledException)) + { + CompilerServerLogger.LogException(e, "Exception while connecting to process"); + return null; + } + } + + public abstract Stream Stream { get; } + + public void Dispose() + { + Dispose(disposing: true); + } + + protected virtual void Dispose(bool disposing) + { + } + private class NamedPipeClient : Client + { + public NamedPipeClient(NamedPipeClientStream stream) + { + Stream = stream; + } + + public override Stream Stream { get; } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + Stream.Dispose(); + } + } + } + } +} diff --git a/src/Microsoft.AspNetCore.Razor.Tools/CommandBase.cs b/src/Microsoft.AspNetCore.Razor.Tools/CommandBase.cs new file mode 100644 index 0000000000..f1a0fef429 --- /dev/null +++ b/src/Microsoft.AspNetCore.Razor.Tools/CommandBase.cs @@ -0,0 +1,52 @@ +// 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; +using System.Threading.Tasks; +using Microsoft.Extensions.CommandLineUtils; + +namespace Microsoft.AspNetCore.Razor.TagHelperTool +{ + internal abstract class CommandBase : CommandLineApplication + { + protected CommandBase(Application parent, string name) + : base(throwOnUnexpectedArg: true) + { + if (parent == null) + { + throw new ArgumentNullException(nameof(parent)); + } + + base.Parent = parent; + Name = name; + + Help = HelpOption("-?|-h|--help"); + OnExecute((Func>)ExecuteAsync); + } + + protected new Application Parent => (Application)base.Parent; + + protected CancellationToken Cancelled => Parent?.CancellationToken ?? default; + + protected CommandOption Help { get; } + + protected virtual bool ValidateArguments() + { + return true; + } + + protected abstract Task ExecuteCoreAsync(); + + private async Task ExecuteAsync() + { + if (!ValidateArguments()) + { + ShowHelp(); + return 1; + } + + return await ExecuteCoreAsync(); + } + } +} diff --git a/src/Microsoft.AspNetCore.Razor.Tools/CompilerHost.cs b/src/Microsoft.AspNetCore.Razor.Tools/CompilerHost.cs new file mode 100644 index 0000000000..3e6a7a5163 --- /dev/null +++ b/src/Microsoft.AspNetCore.Razor.Tools/CompilerHost.cs @@ -0,0 +1,95 @@ +// 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.Collections.Generic; +using System.Threading; +using Microsoft.CodeAnalysis.CommandLine; + +namespace Microsoft.AspNetCore.Razor.TagHelperTool +{ + internal abstract class CompilerHost + { + public static CompilerHost Create() + { + return new DefaultCompilerHost(); + } + + public abstract BuildResponse Execute(BuildRequest request, CancellationToken cancellationToken); + + private class DefaultCompilerHost : CompilerHost + { + public override BuildResponse Execute(BuildRequest request, CancellationToken cancellationToken) + { + if (!TryParseArguments(request, out var parsed)) + { + return new RejectedBuildResponse(); + } + + return null; + } + + private bool TryParseArguments(BuildRequest request, out (string workingDirectory, string tempDirectory, string[] args) parsed) + { + string workingDirectory = null; + string tempDirectory = null; + + // The parsed arguments will contain 'string.Empty' in place of the arguments that we don't want to pass + // to the compiler. + var args = new List(request.Arguments.Count); + + for (var i = 0; i < request.Arguments.Count; i++) + { + args[i] = string.Empty; + + var argument = request.Arguments[i]; + if (argument.ArgumentId == BuildProtocolConstants.ArgumentId.CurrentDirectory) + { + workingDirectory = argument.Value; + } + else if (argument.ArgumentId == BuildProtocolConstants.ArgumentId.TempDirectory) + { + tempDirectory = argument.Value; + } + else if (argument.ArgumentId == BuildProtocolConstants.ArgumentId.CommandLineArgument) + { + args[i] = argument.Value; + } + } + + CompilerServerLogger.Log($"WorkingDirectory = '{workingDirectory}'"); + CompilerServerLogger.Log($"TempDirectory = '{tempDirectory}'"); + for (var i = 0; i < args.Count; i++) + { + CompilerServerLogger.Log($"Argument[{i}] = '{request.Arguments[i]}'"); + } + + if (string.IsNullOrEmpty(workingDirectory)) + { + CompilerServerLogger.Log($"Rejecting build due to missing working directory"); + + parsed = default; + return false; + } + + if (string.IsNullOrEmpty(tempDirectory)) + { + CompilerServerLogger.Log($"Rejecting build due to missing temp directory"); + + parsed = default; + return false; + } + + if (string.IsNullOrEmpty(tempDirectory)) + { + CompilerServerLogger.Log($"Rejecting build due to missing temp directory"); + + parsed = default; + return false; + } + + parsed = (workingDirectory, tempDirectory, args.ToArray()); + return true; + } + } + } +} diff --git a/src/Microsoft.AspNetCore.Razor.Tools/Connection.cs b/src/Microsoft.AspNetCore.Razor.Tools/Connection.cs new file mode 100644 index 0000000000..a1641774ee --- /dev/null +++ b/src/Microsoft.AspNetCore.Razor.Tools/Connection.cs @@ -0,0 +1,28 @@ +// 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.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Razor.TagHelperTool +{ + internal abstract class Connection : IDisposable + { + public string Identifier { get; protected set; } + + public Stream Stream { get; protected set; } + + public abstract Task WaitForDisconnectAsync(CancellationToken cancellationToken); + + public void Dispose() + { + Dispose(disposing: true); + } + + protected virtual void Dispose(bool disposing) + { + } + } +} diff --git a/src/Microsoft.AspNetCore.Razor.Tools/ConnectionHost.cs b/src/Microsoft.AspNetCore.Razor.Tools/ConnectionHost.cs new file mode 100644 index 0000000000..5bcc2e1c50 --- /dev/null +++ b/src/Microsoft.AspNetCore.Razor.Tools/ConnectionHost.cs @@ -0,0 +1,130 @@ +// 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.IO.Pipes; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.CommandLine; + +namespace Microsoft.AspNetCore.Razor.TagHelperTool +{ + // Heavily influenced by: + // https://github.com/dotnet/roslyn/blob/14aed138a01c448143b9acf0fe77a662e3dfe2f4/src/Compilers/Server/VBCSCompiler/NamedPipeClientConnection.cs#L17 + internal abstract class ConnectionHost + { + private static int counter; + + private static string GetNextIdentifier() + { + var id = Interlocked.Increment(ref counter); + return "connection-" + id; + } + + // Size of the buffers to use: 64K + private const int PipeBufferSize = 0x10000; + + public static ConnectionHost Create(string pipeName) + { + return new NamedPipeConnectionHost(pipeName); + } + + public abstract Task WaitForConnectionAsync(CancellationToken cancellationToken); + + private class NamedPipeConnectionHost : ConnectionHost + { + public NamedPipeConnectionHost(string pipeName) + { + PipeName = pipeName; + } + + public string PipeName { get; } + + public async override Task WaitForConnectionAsync(CancellationToken cancellationToken) + { + // Create the pipe and begin waiting for a connection. This doesn't block, but could fail + // in certain circumstances, such as the OS refusing to create the pipe for some reason + // or the pipe was disconnected before we starting listening. + // + // Also, note that we're waiting on CoreFx to implement some security features for us. + // https://github.com/dotnet/corefx/issues/24040 + var pipeStream = new NamedPipeServerStream( + PipeName, + PipeDirection.InOut, + NamedPipeServerStream.MaxAllowedServerInstances, // Maximum connections. + PipeTransmissionMode.Byte, + PipeOptions.Asynchronous | PipeOptions.WriteThrough, + PipeBufferSize, // Default input buffer + PipeBufferSize);// Default output buffer + + CompilerServerLogger.Log("Waiting for new connection"); + await pipeStream.WaitForConnectionAsync(cancellationToken); + CompilerServerLogger.Log("Pipe connection detected."); + + if (Environment.Is64BitProcess || Memory.IsMemoryAvailable()) + { + CompilerServerLogger.Log("Memory available - accepting connection"); + return new NamedPipeConnection(pipeStream, GetNextIdentifier()); + } + + pipeStream.Close(); + throw new Exception("Insufficient resources to process new connection."); + } + } + + private class NamedPipeConnection : Connection + { + public NamedPipeConnection(NamedPipeServerStream stream, string identifier) + { + base.Stream = stream; + Identifier = identifier; + } + + public new NamedPipeServerStream Stream => (NamedPipeServerStream)base.Stream; + + public async override Task WaitForDisconnectAsync(CancellationToken cancellationToken) + { + // We have to poll for disconnection by reading, PipeStream.IsConnected isn't reliable unless you + // actually do a read - which will cause it to update its state. + while (!cancellationToken.IsCancellationRequested && Stream.IsConnected) + { + await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken); + + try + { + CompilerServerLogger.Log($"Before poking pipe {Identifier}."); + await Stream.ReadAsync(Array.Empty(), 0, 0, cancellationToken); + CompilerServerLogger.Log($"After poking pipe {Identifier}."); + } + catch (OperationCanceledException) + { + } + catch (Exception e) + { + // It is okay for this call to fail. Errors will be reflected in the + // IsConnected property which will be read on the next iteration of the + CompilerServerLogger.LogException(e, $"Error poking pipe {Identifier}."); + } + } + } + + protected override void Dispose(bool disposing) + { + CompilerServerLogger.Log($"Pipe {Identifier}: Closing."); + + try + { + Stream.Dispose(); + } + catch (Exception ex) + { + // The client connection failing to close isn't fatal to the server process. It is simply a client + // for which we can no longer communicate and that's okay because the Close method indicates we are + // done with the client already. + var message = string.Format($"Pipe {Identifier}: Error closing pipe."); + CompilerServerLogger.LogException(ex, message); + } + } + } + } +} diff --git a/src/Microsoft.AspNetCore.Razor.Tools/ConnectionResult.cs b/src/Microsoft.AspNetCore.Razor.Tools/ConnectionResult.cs new file mode 100644 index 0000000000..1f187fba4e --- /dev/null +++ b/src/Microsoft.AspNetCore.Razor.Tools/ConnectionResult.cs @@ -0,0 +1,47 @@ +// 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; + +namespace Microsoft.AspNetCore.Razor.TagHelperTool +{ + internal struct ConnectionResult + { + public readonly Reason CloseReason; + public readonly TimeSpan? KeepAlive; + + public ConnectionResult(Reason closeReason, TimeSpan? keepAlive = null) + { + CloseReason = closeReason; + KeepAlive = keepAlive; + } + + public enum Reason + { + /// + /// There was an error creating the request object and a compilation was never created. + /// + CompilationNotStarted, + + /// + /// The compilation completed and results were provided to the client. + /// + CompilationCompleted, + + /// + /// The compilation process was initiated and the client disconnected before the results could be provided to them. + /// + ClientDisconnect, + + /// + /// There was an unhandled exception processing the result. + /// + ClientException, + + /// + /// There was a request from the client to shutdown the server. + /// + ClientShutdownRequest, + } + } +} diff --git a/src/Microsoft.AspNetCore.Razor.Tools/DebugMode.cs b/src/Microsoft.AspNetCore.Razor.Tools/DebugMode.cs new file mode 100644 index 0000000000..38799a9791 --- /dev/null +++ b/src/Microsoft.AspNetCore.Razor.Tools/DebugMode.cs @@ -0,0 +1,27 @@ +// 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.Linq; +using System.Threading; + +namespace Microsoft.AspNetCore.Razor.TagHelperTool +{ + internal static class DebugMode + { + public static void HandleDebugSwitch(ref string[] args) + { + if (args.Length > 0 && string.Equals("--debug", args[0], StringComparison.OrdinalIgnoreCase)) + { + args = args.Skip(1).ToArray(); + + while (!Debugger.IsAttached) + { + Console.WriteLine("Waiting for debugger in pid: {0}", Process.GetCurrentProcess().Id); + Thread.Sleep(TimeSpan.FromSeconds(3)); + } + } + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Razor.Tools/DiscoverCommand.cs b/src/Microsoft.AspNetCore.Razor.Tools/DiscoverCommand.cs new file mode 100644 index 0000000000..319d06abfa --- /dev/null +++ b/src/Microsoft.AspNetCore.Razor.Tools/DiscoverCommand.cs @@ -0,0 +1,156 @@ +// 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.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.Razor.Extensions; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Razor; +using Microsoft.Extensions.CommandLineUtils; +using Microsoft.VisualStudio.LanguageServices.Razor; +using Newtonsoft.Json; + +namespace Microsoft.AspNetCore.Razor.TagHelperTool +{ + internal class DiscoverCommand : CommandBase + { + public DiscoverCommand(Application parent) + : base(parent, "discover") + { + TagHelperManifest = Option("-o", "output file", CommandOptionType.SingleValue); + Assemblies = Argument("assemblies", "assemblies to search for tag helpers", multipleValues: true); + } + + public CommandArgument Assemblies { get; } + + public CommandOption TagHelperManifest { get; } + + protected override bool ValidateArguments() + { + if (string.IsNullOrEmpty(TagHelperManifest.Value())) + { + Error.WriteLine($"{TagHelperManifest.ValueName} must be specified."); + return false; + } + + if (Assemblies.Values.Count == 0) + { + Error.WriteLine($"{Assemblies.Name} must have at least one value."); + return false; + } + + return true; + } + + protected override Task ExecuteCoreAsync() + { + var result = ExecuteCore( + outputFilePath: TagHelperManifest.Value(), + assemblies: Assemblies.Values.ToArray()); + return Task.FromResult(result); + } + + private int ExecuteCore(string outputFilePath, string[] assemblies) + { + var metadataReferences = new MetadataReference[assemblies.Length]; + for (var i = 0; i < assemblies.Length; i++) + { + metadataReferences[i] = MetadataReference.CreateFromFile(assemblies[i]); + } + + var engine = RazorEngine.Create((b) => + { + RazorExtensions.Register(b); + + b.Features.Add(new DefaultMetadataReferenceFeature() { References = metadataReferences }); + b.Features.Add(new CompilationTagHelperFeature()); + + // TagHelperDescriptorProviders (actually do tag helper discovery) + b.Features.Add(new Microsoft.CodeAnalysis.Razor.DefaultTagHelperDescriptorProvider()); + b.Features.Add(new ViewComponentTagHelperDescriptorProvider()); + }); + + var feature = engine.Features.OfType().Single(); + var tagHelpers = feature.GetDescriptors(); + + using (var stream = new MemoryStream()) + { + Serialize(stream, tagHelpers); + + stream.Position = 0L; + + var newHash = Hash(stream); + var existingHash = Hash(outputFilePath); + + if (!HashesEqual(newHash, existingHash)) + { + stream.Position = 0; + using (var output = File.OpenWrite(outputFilePath)) + { + stream.CopyTo(output); + } + } + } + + return 0; + } + + private static byte[] Hash(string path) + { + if (!File.Exists(path)) + { + return Array.Empty(); + } + + using (var stream = File.OpenRead(path)) + { + return Hash(stream); + } + } + + private static byte[] Hash(Stream stream) + { + using (var sha = SHA256.Create()) + { + sha.ComputeHash(stream); + return sha.Hash; + } + } + + private bool HashesEqual(byte[] x, byte[] y) + { + if (x.Length != y.Length) + { + return false; + } + + for (var i = 0; i < x.Length; i++) + { + if (x[i] != y[i]) + { + return false; + } + } + + return true; + } + + private static void Serialize(Stream stream, IReadOnlyList tagHelpers) + { + using (var writer = new StreamWriter(stream, Encoding.UTF8, bufferSize: 4096, leaveOpen: true)) + { + var serializer = new JsonSerializer(); + serializer.Converters.Add(new TagHelperDescriptorJsonConverter()); + serializer.Converters.Add(new RazorDiagnosticJsonConverter()); + + serializer.Serialize(writer, tagHelpers); + } + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Razor.Tools/EventBus.cs b/src/Microsoft.AspNetCore.Razor.Tools/EventBus.cs new file mode 100644 index 0000000000..964414be9b --- /dev/null +++ b/src/Microsoft.AspNetCore.Razor.Tools/EventBus.cs @@ -0,0 +1,61 @@ +// 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; + +namespace Microsoft.AspNetCore.Razor.TagHelperTool +{ + internal abstract class EventBus + { + public static readonly EventBus Default = new DefaultEventBus(); + + /// + /// Called when the server updates the keep alive value. + /// + public virtual void UpdateKeepAlive(TimeSpan timeSpan) + { + } + + /// + /// Called each time the server listens for new connections. + /// + public virtual void ConnectionListening() + { + } + + /// + /// Called when a connection to the server occurs. + /// + public virtual void ConnectionReceived() + { + } + + /// + /// Called when one or more connections have completed processing. The number of connections + /// processed is provided in . + /// + public virtual void ConnectionCompleted(int count) + { + } + + /// + /// Called when a bad client connection was detected and the server will be shutting down as a + /// result. + /// + public virtual void ConnectionRudelyEnded() + { + } + + /// + /// Called when the server is shutting down because the keep alive timeout was reached. + /// + public virtual void KeepAliveReached() + { + } + + private class DefaultEventBus : EventBus + { + + } + } +} diff --git a/src/Microsoft.AspNetCore.Razor.Tools/Memory.cs b/src/Microsoft.AspNetCore.Razor.Tools/Memory.cs new file mode 100644 index 0000000000..aee865fa51 --- /dev/null +++ b/src/Microsoft.AspNetCore.Razor.Tools/Memory.cs @@ -0,0 +1,13 @@ +// 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. + +namespace Microsoft.AspNetCore.Razor.TagHelperTool +{ + internal static class Memory + { + public static bool IsMemoryAvailable() + { + return true; + } + } +} diff --git a/src/Microsoft.AspNetCore.Razor.Tools/Microsoft.AspNetCore.Razor.Tools.csproj b/src/Microsoft.AspNetCore.Razor.Tools/Microsoft.AspNetCore.Razor.Tools.csproj new file mode 100644 index 0000000000..286a6310ae --- /dev/null +++ b/src/Microsoft.AspNetCore.Razor.Tools/Microsoft.AspNetCore.Razor.Tools.csproj @@ -0,0 +1,32 @@ + + + + Razor is a markup syntax for adding server-side logic to web pages. This assembly contains infrastructure supporting Razor MSBuild integration. + + netcoreapp2.0 + Exe + rzc + + + false + false + + + + + Shared\RazorDiagnosticJsonConverter.cs + + + Shared\TagHelperDescriptorJsonConverter.cs + + + + + + + + + + + + diff --git a/src/Microsoft.AspNetCore.Razor.Tools/MutexName.cs b/src/Microsoft.AspNetCore.Razor.Tools/MutexName.cs new file mode 100644 index 0000000000..174dfbf433 --- /dev/null +++ b/src/Microsoft.AspNetCore.Razor.Tools/MutexName.cs @@ -0,0 +1,18 @@ +// 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. + +namespace Microsoft.AspNetCore.Razor.TagHelperTool +{ + internal static class MutexName + { + public static string GetClientMutexName(string pipeName) + { + return $"{pipeName}.client"; + } + + public static string GetServerMutexName(string pipeName) + { + return $"{pipeName}.server"; + } + } +} diff --git a/src/Microsoft.AspNetCore.Razor.Tools/PipeName.cs b/src/Microsoft.AspNetCore.Razor.Tools/PipeName.cs new file mode 100644 index 0000000000..9007d89cd9 --- /dev/null +++ b/src/Microsoft.AspNetCore.Razor.Tools/PipeName.cs @@ -0,0 +1,67 @@ +// 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.IO; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; + +namespace Microsoft.AspNetCore.Razor.TagHelperTool +{ + internal static class PipeName + { + // We want each pipe to unique and predictable based on the inputs of: + // - user (security) + // - elevation status (security) + // - path of tool on disk (version) + // + // This allows us to meet the security and version compat requirements just by selecting a pipe name. + // + // https://github.com/dotnet/corefx/issues/25427 will actually enforce the security, but we still + // want these guarantees when we try to connect so we can expect it to succeed. + // + // This is similar to (and based on) the code used by Roslyn in VBCSCompiler: + // https://github.com/dotnet/roslyn/blob/c273b6a9f19570a344c274ae89185b3a2b64d93d/src/Compilers/Shared/BuildServerConnection.cs#L528 + public static string ComputeDefault() + { + // Include a prefix so we can't conflict with VBCSCompiler if we somehow end up in the same directory. + // That would be a pretty wacky bug to try and unravel. + var baseName = ComputeBaseName("Razor:" + AppDomain.CurrentDomain.BaseDirectory); + + // Prefix with username and elevation + bool isAdmin = false; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { +#if WINDOWS_HACK_LOL + var currentIdentity = WindowsIdentity.GetCurrent(); + var principal = new WindowsPrincipal(currentIdentity); + isAdmin = principal.IsInRole(WindowsBuiltInRole.Administrator); +#endif + } + + var userName = Environment.UserName; + if (userName == null) + { + return null; + } + + return $"{userName}.{isAdmin}.{baseName}"; + } + + private static string ComputeBaseName(string baseDirectory) + { + // Normalize away trailing slashes. File APIs are not consistent about including it, so it's + // best to normalize and avoid ending up with two servers running accidentally. + baseDirectory = baseDirectory.TrimEnd(Path.DirectorySeparatorChar); + + using (var sha = SHA256.Create()) + { + var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(baseDirectory)); + return Convert.ToBase64String(bytes) + .Replace("/", "_") + .Replace("=", string.Empty); + } + } + } +} diff --git a/src/Microsoft.AspNetCore.Razor.Tools/Program.cs b/src/Microsoft.AspNetCore.Razor.Tools/Program.cs new file mode 100644 index 0000000000..6dfb12d1ea --- /dev/null +++ b/src/Microsoft.AspNetCore.Razor.Tools/Program.cs @@ -0,0 +1,22 @@ +// 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; + +namespace Microsoft.AspNetCore.Razor.TagHelperTool +{ + internal static class Program + { + public static int Main(string[] args) + { + DebugMode.HandleDebugSwitch(ref args); + + var cancel = new CancellationTokenSource(); + Console.CancelKeyPress += (sender, e) => { cancel.Cancel(); }; + + var application = new Application(cancel.Token); + return application.Execute(args); + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Razor.Tools/RequestDispatcher.cs b/src/Microsoft.AspNetCore.Razor.Tools/RequestDispatcher.cs new file mode 100644 index 0000000000..5b69d2caed --- /dev/null +++ b/src/Microsoft.AspNetCore.Razor.Tools/RequestDispatcher.cs @@ -0,0 +1,486 @@ +// 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; +using System.Linq; +using System.Runtime; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.CommandLine; +using Microsoft.CodeAnalysis.CompilerServer; + +namespace Microsoft.AspNetCore.Razor.TagHelperTool +{ + // Heavily influenced by: + // https://github.com/dotnet/roslyn/blob/14aed138a01c448143b9acf0fe77a662e3dfe2f4/src/Compilers/Server/ServerShared/ServerDispatcher.cs#L15 + internal abstract class RequestDispatcher + { + public static RequestDispatcher Create(ConnectionHost connectionHost, CompilerHost compilerHost, CancellationToken cancellationToken) + { + return new DefaultRequestDispatcher(connectionHost, compilerHost, cancellationToken); + } + + /// + /// Default time the server will stay alive after the last request disconnects. + /// + public static readonly TimeSpan DefaultServerKeepAlive = TimeSpan.FromMinutes(10); + + /// + /// Time to delay after the last connection before initiating a garbage collection + /// in the server. + /// + public static readonly TimeSpan GCTimeout = TimeSpan.FromSeconds(30); + + public abstract void Run(); + + private enum State + { + /// + /// Server running and accepting all requests + /// + Running, + + /// + /// Server processing existing requests, responding to shutdown commands but is not accepting + /// new build requests. + /// + ShuttingDown, + + /// + /// Server is done. + /// + Completed, + } + + private class DefaultRequestDispatcher : RequestDispatcher + { + private readonly CancellationToken _cancellationToken; + private readonly CompilerHost _compilerHost; + private readonly ConnectionHost _connectionHost; + private readonly EventBus _eventBus; + + private KeepAlive _keepAlive; + private State _state; + private Task _timeoutTask; + private Task _gcTask; + private Task _listenTask; + private CancellationTokenSource _listenCancellationTokenSource; + private List> _connections = new List>(); + + + public DefaultRequestDispatcher(ConnectionHost connectionHost, CompilerHost compilerHost, CancellationToken cancellationToken) + { + _connectionHost = connectionHost; + _compilerHost = compilerHost; + _cancellationToken = cancellationToken; + + _eventBus = EventBus.Default; + _keepAlive = new KeepAlive(DefaultServerKeepAlive, isDefault: true); + } + + // The server accepts connections until we reach a state that requires a shutdown. At that + // time no new connections will be accepted and the server will drain existing connections. + // + // The idea is that it's better to let clients fallback to in-proc (and slow down) than it is to keep + // running in an undesired state. + public override void Run() + { + _state = State.Running; + + try + { + Listen(); + + do + { + Debug.Assert(_listenTask != null); + + MaybeCreateTimeoutTask(); + MaybeCreateGCTask(); + WaitForAnyCompletion(_cancellationToken); + CheckCompletedTasks(_cancellationToken); + } + while (_connections.Count > 0 || _state == State.Running); + } + finally + { + _state = State.Completed; + _gcTask = null; + _timeoutTask = null; + + if (_listenTask != null) + { + CloseListenTask(); + } + } + } + + + private void CheckCompletedTasks(CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + { + HandleCancellation(); + return; + } + + if (_listenTask.IsCompleted) + { + HandleCompletedListenTask(cancellationToken); + } + + if (_timeoutTask?.IsCompleted == true) + { + HandleCompletedTimeoutTask(); + } + + if (_gcTask?.IsCompleted == true) + { + HandleCompletedGCTask(); + } + + HandleCompletedConnections(); + } + + private void HandleCancellation() + { + Debug.Assert(_listenTask != null); + + // If cancellation has been requested then the server needs to be in the process + // of shutting down. + _state = State.ShuttingDown; + + CloseListenTask(); + + try + { + Task.WaitAll(_connections.ToArray()); + } + catch + { + // It's expected that some will throw exceptions, in particular OperationCanceledException. It's + // okay for them to throw so long as they complete. + } + + HandleCompletedConnections(); + Debug.Assert(_connections.Count == 0); + } + + /// + /// The server farms out work to Task values and this method needs to wait until at least one of them + /// has completed. + /// + private void WaitForAnyCompletion(CancellationToken cancellationToken) + { + var all = new List(); + all.AddRange(_connections); + all.Add(_timeoutTask); + all.Add(_listenTask); + all.Add(_gcTask); + + try + { + var waitArray = all.Where(x => x != null).ToArray(); + Task.WaitAny(waitArray, cancellationToken); + } + catch (OperationCanceledException) + { + // Thrown when the provided cancellationToken is cancelled. This is handled in the caller, + // here it just serves to break out of the WaitAny call. + } + } + + private void Listen() + { + Debug.Assert(_listenTask == null); + Debug.Assert(_timeoutTask == null); + + _listenCancellationTokenSource = new CancellationTokenSource(); + _listenTask = _connectionHost.WaitForConnectionAsync(_listenCancellationTokenSource.Token); + _eventBus.ConnectionListening(); + } + + private void CloseListenTask() + { + Debug.Assert(_listenTask != null); + + _listenCancellationTokenSource.Cancel(); + _listenCancellationTokenSource = null; + _listenTask = null; + } + + private void HandleCompletedListenTask(CancellationToken cancellationToken) + { + _eventBus.ConnectionReceived(); + + // Don't accept any new connections once we're in shutdown mode, instead gracefully reject the request. + // This should cause the client to run in process. + var accept = _state == State.Running; + var connectionTask = AcceptConnection(_listenTask, accept, cancellationToken); + _connections.Add(connectionTask); + + // Timeout and GC are only done when there are no active connections. Now that we have a new + // connection cancel out these tasks. + _timeoutTask = null; + _gcTask = null; + + // Begin listening again for new connections. + _listenTask = null; + Listen(); + } + + private void HandleCompletedTimeoutTask() + { + _eventBus.KeepAliveReached(); + _listenCancellationTokenSource.Cancel(); + _timeoutTask = null; + _state = State.ShuttingDown; + } + + private void HandleCompletedGCTask() + { + _gcTask = null; + for (int i = 0; i < 10; i++) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + } + + GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce; + GC.Collect(); + } + + private void MaybeCreateTimeoutTask() + { + // If there are no active clients running then the server needs to be in a timeout mode. + if (_connections.Count == 0 && _timeoutTask == null) + { + Debug.Assert(_listenTask != null); + _timeoutTask = Task.Delay(_keepAlive.TimeSpan); + } + } + + private void MaybeCreateGCTask() + { + if (_connections.Count == 0 && _gcTask == null) + { + _gcTask = Task.Delay(GCTimeout); + } + } + + /// + /// Checks the completed connection objects. + /// + /// False if the server needs to begin shutting down + private void HandleCompletedConnections() + { + var shutdown = false; + var processedCount = 0; + var i = 0; + while (i < _connections.Count) + { + var current = _connections[i]; + if (!current.IsCompleted) + { + i++; + continue; + } + + _connections.RemoveAt(i); + processedCount++; + + var result = current.Result; + if (result.KeepAlive.HasValue) + { + var updated = _keepAlive.Update(result.KeepAlive.Value); + if (updated.Equals(_keepAlive)) + { + _eventBus.UpdateKeepAlive(updated.TimeSpan); + } + } + + switch (result.CloseReason) + { + case ConnectionResult.Reason.CompilationCompleted: + case ConnectionResult.Reason.CompilationNotStarted: + // These are all normal end states. Nothing to do here. + break; + + case ConnectionResult.Reason.ClientDisconnect: + // Have to assume the worst here which is user pressing Ctrl+C at the command line and + // hence wanting all compilation to end. + _eventBus.ConnectionRudelyEnded(); + shutdown = true; + break; + + case ConnectionResult.Reason.ClientException: + case ConnectionResult.Reason.ClientShutdownRequest: + _eventBus.ConnectionRudelyEnded(); + shutdown = true; + break; + + default: + throw new InvalidOperationException($"Unexpected enum value {result.CloseReason}"); + } + } + + if (processedCount > 0) + { + _eventBus.ConnectionCompleted(processedCount); + } + + if (shutdown) + { + _state = State.ShuttingDown; + } + } + + internal async Task AcceptConnection(Task task, bool accept, CancellationToken cancellationToken) + { + Connection connection; + try + { + connection = await task; + } + catch (Exception ex) + { + // Unable to establish a connection with the client. The client is responsible for + // handling this case. Nothing else for us to do here. + CompilerServerLogger.LogException(ex, "Error creating client named pipe"); + return new ConnectionResult(ConnectionResult.Reason.CompilationNotStarted); + } + + try + { + using (connection) + { + BuildRequest request; + try + { + CompilerServerLogger.Log("Begin reading request."); + request = await BuildRequest.ReadAsync(connection.Stream, cancellationToken).ConfigureAwait(false); + CompilerServerLogger.Log("End reading request."); + } + catch (Exception e) + { + CompilerServerLogger.LogException(e, "Error reading build request."); + return new ConnectionResult(ConnectionResult.Reason.CompilationNotStarted); + } + + if (request.IsShutdownRequest()) + { + // Reply with the PID of this process so that the client can wait for it to exit. + var response = new ShutdownBuildResponse(Process.GetCurrentProcess().Id); + await response.WriteAsync(connection.Stream, cancellationToken); + + // We can safely disconnect the client, then when this connection gets cleaned up by the event loop + // the server will go to a shutdown state. + return new ConnectionResult(ConnectionResult.Reason.ClientShutdownRequest); + } + else if (!accept) + { + // We're already in shutdown mode, respond gracefully so the client can run in-process. + var response = new RejectedBuildResponse(); + await response.WriteAsync(connection.Stream, cancellationToken).ConfigureAwait(false); + + return new ConnectionResult(ConnectionResult.Reason.CompilationNotStarted); + } + else + { + // If we get here then this is a real request that we will accept and process. + // + // Kick off both the compilation and a task to monitor the pipe for closing. + var buildCancelled = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + var watcher = connection.WaitForDisconnectAsync(buildCancelled.Token); + var worker = ExecuteRequestAsync(request, buildCancelled.Token); + + // await will end when either the work is complete or the connection is closed. + await Task.WhenAny(worker, watcher); + + // Do an 'await' on the completed task, preference being compilation, to force + // any exceptions to be realized in this method for logging. + ConnectionResult.Reason reason; + if (worker.IsCompleted) + { + var response = await worker; + + try + { + CompilerServerLogger.Log("Begin writing response."); + await response.WriteAsync(connection.Stream, cancellationToken); + CompilerServerLogger.Log("End writing response."); + + reason = ConnectionResult.Reason.CompilationCompleted; + } + catch + { + reason = ConnectionResult.Reason.ClientDisconnect; + } + } + else + { + await watcher; + reason = ConnectionResult.Reason.ClientDisconnect; + } + + // Begin the tear down of the Task which didn't complete. + buildCancelled.Cancel(); + + return new ConnectionResult(reason, request.KeepAlive); + } + } + } + catch (Exception ex) + { + CompilerServerLogger.LogException(ex, "Error handling connection"); + return new ConnectionResult(ConnectionResult.Reason.ClientException); + } + } + + private Task ExecuteRequestAsync(BuildRequest buildRequest, CancellationToken cancellationToken) + { + Func func = () => + { + CompilerServerLogger.Log("Begin processing request"); + + + // TODO: this is where we actually process the request. + // Take a look at BuildProtocolUtil + var response = (BuildResponse)null; + + CompilerServerLogger.Log("End processing request"); + return response; + }; + + var task = new Task(func, cancellationToken, TaskCreationOptions.LongRunning); + task.Start(); + return task; + } + } + + private struct KeepAlive + { + public TimeSpan TimeSpan; + public bool IsDefault; + + public KeepAlive(TimeSpan timeSpan, bool isDefault) + { + TimeSpan = timeSpan; + IsDefault = isDefault; + } + + public KeepAlive Update(TimeSpan timeSpan) + { + if (IsDefault || timeSpan > TimeSpan) + { + return new KeepAlive(timeSpan, isDefault: false); + } + + return this; + } + } + } +} diff --git a/src/Microsoft.AspNetCore.Razor.Tools/Roslyn/BuildProtocol.cs b/src/Microsoft.AspNetCore.Razor.Tools/Roslyn/BuildProtocol.cs new file mode 100644 index 0000000000..9d90a35ecc --- /dev/null +++ b/src/Microsoft.AspNetCore.Razor.Tools/Roslyn/BuildProtocol.cs @@ -0,0 +1,598 @@ +// Copyright (c) Microsoft. 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.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using static Microsoft.CodeAnalysis.CommandLine.BuildProtocolConstants; +using static Microsoft.CodeAnalysis.CommandLine.CompilerServerLogger; + +// This file describes data structures about the protocol from client program to server that is +// used. The basic protocol is this. +// +// After the server pipe is connected, it forks off a thread to handle the connection, and creates +// a new instance of the pipe to listen for new clients. When it gets a request, it validates +// the security and elevation level of the client. If that fails, it disconnects the client. Otherwise, +// it handles the request, sends a response (described by Response class) back to the client, then +// disconnects the pipe and ends the thread. + +namespace Microsoft.CodeAnalysis.CommandLine +{ + /// + /// Represents a request from the client. A request is as follows. + /// + /// Field Name Type Size (bytes) + /// ---------------------------------------------------- + /// Length Integer 4 + /// Language RequestLanguage 4 + /// Argument Count UInteger 4 + /// Arguments Argument[] Variable + /// + /// See for the format of an + /// Argument. + /// + /// + internal class BuildRequest + { + public readonly uint ProtocolVersion; + public readonly RequestLanguage Language; + public readonly ReadOnlyCollection Arguments; + + public BuildRequest(uint protocolVersion, RequestLanguage language, IEnumerable arguments) + { + ProtocolVersion = protocolVersion; + Language = language; + Arguments = new ReadOnlyCollection(arguments.ToList()); + + if (Arguments.Count > ushort.MaxValue) + { + throw new ArgumentOutOfRangeException( + nameof(arguments), + $"Too many arguments: maximum of {ushort.MaxValue} arguments allowed."); + } + } + + public TimeSpan? KeepAlive + { + get + { + TimeSpan? keepAlive = null; + foreach (var argument in Arguments) + { + if (argument.ArgumentId == BuildProtocolConstants.ArgumentId.KeepAlive) + { + // If the value is not a valid integer for any reason,ignore it and continue with the current timeout. + // The client is responsible for validating the argument. + if (int.TryParse(argument.Value, out var result)) + { + // Keep alive times are specified in seconds + keepAlive = TimeSpan.FromSeconds(result); + } + } + } + + return keepAlive; + } + } + + public static BuildRequest Create( + RequestLanguage language, + string workingDirectory, + string tempDirectory, + IList args, + string keepAlive = null, + string libDirectory = null) + { + Log("Creating BuildRequest"); + Log($"Working directory: {workingDirectory}"); + Log($"Temp directory: {tempDirectory}"); + Log($"Lib directory: {libDirectory ?? "null"}"); + + var requestLength = args.Count + 1 + (libDirectory == null ? 0 : 1); + var requestArgs = new List(requestLength); + + requestArgs.Add(new Argument(ArgumentId.CurrentDirectory, 0, workingDirectory)); + requestArgs.Add(new Argument(ArgumentId.TempDirectory, 0, tempDirectory)); + + if (keepAlive != null) + { + requestArgs.Add(new Argument(ArgumentId.KeepAlive, 0, keepAlive)); + } + + if (libDirectory != null) + { + requestArgs.Add(new Argument(ArgumentId.LibEnvVariable, 0, libDirectory)); + } + + for (int i = 0; i < args.Count; ++i) + { + var arg = args[i]; + Log($"argument[{i}] = {arg}"); + requestArgs.Add(new Argument(ArgumentId.CommandLineArgument, i, arg)); + } + + return new BuildRequest(BuildProtocolConstants.ProtocolVersion, language, requestArgs); + } + + public static BuildRequest CreateShutdown() + { + var requestArgs = new[] { new Argument(ArgumentId.Shutdown, argumentIndex: 0, value: "") }; + return new BuildRequest(BuildProtocolConstants.ProtocolVersion, RequestLanguage.CSharpCompile, requestArgs); + } + + public bool IsShutdownRequest() + { + return Arguments.Count == 1 && Arguments[0].ArgumentId == ArgumentId.Shutdown; + } + + /// + /// Read a Request from the given stream. + /// + /// The total request size must be less than 1MB. + /// + /// null if the Request was too large, the Request otherwise. + public static async Task ReadAsync(Stream inStream, CancellationToken cancellationToken) + { + // Read the length of the request + var lengthBuffer = new byte[4]; + Log("Reading length of request"); + await ReadAllAsync(inStream, lengthBuffer, 4, cancellationToken).ConfigureAwait(false); + var length = BitConverter.ToInt32(lengthBuffer, 0); + + // Back out if the request is > 1MB + if (length > 0x100000) + { + Log("Request is over 1MB in length, cancelling read."); + return null; + } + + cancellationToken.ThrowIfCancellationRequested(); + + // Read the full request + var requestBuffer = new byte[length]; + await ReadAllAsync(inStream, requestBuffer, length, cancellationToken).ConfigureAwait(false); + + cancellationToken.ThrowIfCancellationRequested(); + + Log("Parsing request"); + // Parse the request into the Request data structure. + using (var reader = new BinaryReader(new MemoryStream(requestBuffer), Encoding.Unicode)) + { + var protocolVersion = reader.ReadUInt32(); + var language = (RequestLanguage)reader.ReadUInt32(); + uint argumentCount = reader.ReadUInt32(); + + var argumentsBuilder = new List((int)argumentCount); + + for (int i = 0; i < argumentCount; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + argumentsBuilder.Add(BuildRequest.Argument.ReadFromBinaryReader(reader)); + } + + return new BuildRequest(protocolVersion, + language, + argumentsBuilder); + } + } + + /// + /// Write a Request to the stream. + /// + public async Task WriteAsync(Stream outStream, CancellationToken cancellationToken = default(CancellationToken)) + { + using (var memoryStream = new MemoryStream()) + using (var writer = new BinaryWriter(memoryStream, Encoding.Unicode)) + { + // Format the request. + Log("Formatting request"); + writer.Write(ProtocolVersion); + writer.Write((uint)Language); + writer.Write(Arguments.Count); + foreach (Argument arg in Arguments) + { + cancellationToken.ThrowIfCancellationRequested(); + arg.WriteToBinaryWriter(writer); + } + writer.Flush(); + + cancellationToken.ThrowIfCancellationRequested(); + + // Write the length of the request + int length = checked((int)memoryStream.Length); + + // Back out if the request is > 1 MB + if (memoryStream.Length > 0x100000) + { + Log("Request is over 1MB in length, cancelling write"); + throw new ArgumentOutOfRangeException(); + } + + // Send the request to the server + Log("Writing length of request."); + await outStream.WriteAsync(BitConverter.GetBytes(length), 0, 4, + cancellationToken).ConfigureAwait(false); + + Log("Writing request of size {0}", length); + // Write the request + memoryStream.Position = 0; + await memoryStream.CopyToAsync(outStream, bufferSize: length, cancellationToken: cancellationToken).ConfigureAwait(false); + } + } + + /// + /// A command line argument to the compilation. + /// An argument is formatted as follows: + /// + /// Field Name Type Size (bytes) + /// -------------------------------------------------- + /// ID UInteger 4 + /// Index UInteger 4 + /// Value String Variable + /// + /// Strings are encoded via a length prefix as a signed + /// 32-bit integer, followed by an array of characters. + /// + public struct Argument + { + public readonly ArgumentId ArgumentId; + public readonly int ArgumentIndex; + public readonly string Value; + + public Argument(ArgumentId argumentId, + int argumentIndex, + string value) + { + ArgumentId = argumentId; + ArgumentIndex = argumentIndex; + Value = value; + } + + public static Argument ReadFromBinaryReader(BinaryReader reader) + { + var argId = (ArgumentId)reader.ReadInt32(); + var argIndex = reader.ReadInt32(); + string value = ReadLengthPrefixedString(reader); + return new Argument(argId, argIndex, value); + } + + public void WriteToBinaryWriter(BinaryWriter writer) + { + writer.Write((int)ArgumentId); + writer.Write(ArgumentIndex); + WriteLengthPrefixedString(writer, Value); + } + } + } + + /// + /// Base class for all possible responses to a request. + /// The ResponseType enum should list all possible response types + /// and ReadResponse creates the appropriate response subclass based + /// on the response type sent by the client. + /// The format of a response is: + /// + /// Field Name Field Type Size (bytes) + /// ------------------------------------------------- + /// responseLength int (positive) 4 + /// responseType enum ResponseType 4 + /// responseBody Response subclass variable + /// + internal abstract class BuildResponse + { + public enum ResponseType + { + // The client and server are using incompatible protocol versions. + MismatchedVersion, + + // The build request completed on the server and the results are contained + // in the message. + Completed, + + // The build request could not be run on the server due because it created + // an unresolvable inconsistency with analyzers. + AnalyzerInconsistency, + + // The shutdown request completed and the server process information is + // contained in the message. + Shutdown, + + // The request was rejected by the server. + Rejected, + } + + public abstract ResponseType Type { get; } + + public async Task WriteAsync(Stream outStream, + CancellationToken cancellationToken) + { + using (var memoryStream = new MemoryStream()) + using (var writer = new BinaryWriter(memoryStream, Encoding.Unicode)) + { + // Format the response + Log("Formatting Response"); + writer.Write((int)Type); + + AddResponseBody(writer); + writer.Flush(); + + cancellationToken.ThrowIfCancellationRequested(); + + // Send the response to the client + + // Write the length of the response + int length = checked((int)memoryStream.Length); + + Log("Writing response length"); + // There is no way to know the number of bytes written to + // the pipe stream. We just have to assume all of them are written. + await outStream.WriteAsync(BitConverter.GetBytes(length), + 0, + 4, + cancellationToken).ConfigureAwait(false); + + // Write the response + Log("Writing response of size {0}", length); + memoryStream.Position = 0; + await memoryStream.CopyToAsync(outStream, bufferSize: length, cancellationToken: cancellationToken).ConfigureAwait(false); + } + } + + protected abstract void AddResponseBody(BinaryWriter writer); + + /// + /// May throw exceptions if there are pipe problems. + /// + /// + /// + /// + public static async Task ReadAsync(Stream stream, CancellationToken cancellationToken = default(CancellationToken)) + { + Log("Reading response length"); + // Read the response length + var lengthBuffer = new byte[4]; + await ReadAllAsync(stream, lengthBuffer, 4, cancellationToken).ConfigureAwait(false); + var length = BitConverter.ToUInt32(lengthBuffer, 0); + + // Read the response + Log("Reading response of length {0}", length); + var responseBuffer = new byte[length]; + await ReadAllAsync(stream, + responseBuffer, + responseBuffer.Length, + cancellationToken).ConfigureAwait(false); + + using (var reader = new BinaryReader(new MemoryStream(responseBuffer), Encoding.Unicode)) + { + var responseType = (ResponseType)reader.ReadInt32(); + + switch (responseType) + { + case ResponseType.Completed: + return CompletedBuildResponse.Create(reader); + case ResponseType.MismatchedVersion: + return new MismatchedVersionBuildResponse(); + case ResponseType.AnalyzerInconsistency: + return new AnalyzerInconsistencyBuildResponse(); + case ResponseType.Shutdown: + return ShutdownBuildResponse.Create(reader); + case ResponseType.Rejected: + return new RejectedBuildResponse(); + default: + throw new InvalidOperationException("Received invalid response type from server."); + } + } + } + } + + /// + /// Represents a Response from the server. A response is as follows. + /// + /// Field Name Type Size (bytes) + /// -------------------------------------------------- + /// Length UInteger 4 + /// ReturnCode Integer 4 + /// Output String Variable + /// ErrorOutput String Variable + /// + /// Strings are encoded via a character count prefix as a + /// 32-bit integer, followed by an array of characters. + /// + /// + internal sealed class CompletedBuildResponse : BuildResponse + { + public readonly int ReturnCode; + public readonly bool Utf8Output; + public readonly string Output; + public readonly string ErrorOutput; + + public CompletedBuildResponse(int returnCode, + bool utf8output, + string output) + { + ReturnCode = returnCode; + Utf8Output = utf8output; + Output = output; + + // This field existed to support writing to Console.Error. The compiler doesn't ever write to + // this field or Console.Error. This field is only kept around in order to maintain the existing + // protocol semantics. + ErrorOutput = string.Empty; + } + + public override ResponseType Type => ResponseType.Completed; + + public static CompletedBuildResponse Create(BinaryReader reader) + { + var returnCode = reader.ReadInt32(); + var utf8Output = reader.ReadBoolean(); + var output = ReadLengthPrefixedString(reader); + var errorOutput = ReadLengthPrefixedString(reader); + if (!string.IsNullOrEmpty(errorOutput)) + { + throw new InvalidOperationException(); + } + + return new CompletedBuildResponse(returnCode, utf8Output, output); + } + + protected override void AddResponseBody(BinaryWriter writer) + { + writer.Write(ReturnCode); + writer.Write(Utf8Output); + WriteLengthPrefixedString(writer, Output); + WriteLengthPrefixedString(writer, ErrorOutput); + } + } + + internal sealed class ShutdownBuildResponse : BuildResponse + { + public readonly int ServerProcessId; + + public ShutdownBuildResponse(int serverProcessId) + { + ServerProcessId = serverProcessId; + } + + public override ResponseType Type => ResponseType.Shutdown; + + protected override void AddResponseBody(BinaryWriter writer) + { + writer.Write(ServerProcessId); + } + + public static ShutdownBuildResponse Create(BinaryReader reader) + { + var serverProcessId = reader.ReadInt32(); + return new ShutdownBuildResponse(serverProcessId); + } + } + + internal sealed class MismatchedVersionBuildResponse : BuildResponse + { + public override ResponseType Type => ResponseType.MismatchedVersion; + + /// + /// MismatchedVersion has no body. + /// + protected override void AddResponseBody(BinaryWriter writer) { } + } + + internal sealed class AnalyzerInconsistencyBuildResponse : BuildResponse + { + public override ResponseType Type => ResponseType.AnalyzerInconsistency; + + /// + /// AnalyzerInconsistency has no body. + /// + /// + protected override void AddResponseBody(BinaryWriter writer) { } + } + + internal sealed class RejectedBuildResponse : BuildResponse + { + public override ResponseType Type => ResponseType.Rejected; + + /// + /// AnalyzerInconsistency has no body. + /// + /// + protected override void AddResponseBody(BinaryWriter writer) { } + } + + // The id numbers below are just random. It's useful to use id numbers + // that won't occur accidentally for debugging. + internal enum RequestLanguage + { + CSharpCompile = 0x44532521, + VisualBasicCompile = 0x44532522, + } + + /// + /// Constants about the protocol. + /// + internal static class BuildProtocolConstants + { + /// + /// The version number for this protocol. + /// + public const uint ProtocolVersion = 2; + + // Arguments for CSharp and VB Compiler + public enum ArgumentId + { + // The current directory of the client + CurrentDirectory = 0x51147221, + + // A comment line argument. The argument index indicates which one (0 .. N) + CommandLineArgument, + + // The "LIB" environment variable of the client + LibEnvVariable, + + // Request a longer keep alive time for the server + KeepAlive, + + // Request a server shutdown from the client + Shutdown, + + // The directory to use for temporary operations. + TempDirectory, + } + + /// + /// Read a string from the Reader where the string is encoded + /// as a length prefix (signed 32-bit integer) followed by + /// a sequence of characters. + /// + public static string ReadLengthPrefixedString(BinaryReader reader) + { + var length = reader.ReadInt32(); + return new String(reader.ReadChars(length)); + } + + /// + /// Write a string to the Writer where the string is encoded + /// as a length prefix (signed 32-bit integer) follows by + /// a sequence of characters. + /// + public static void WriteLengthPrefixedString(BinaryWriter writer, string value) + { + writer.Write(value.Length); + writer.Write(value.ToCharArray()); + } + + /// + /// This task does not complete until we are completely done reading. + /// + internal static async Task ReadAllAsync( + Stream stream, + byte[] buffer, + int count, + CancellationToken cancellationToken) + { + int totalBytesRead = 0; + do + { + Log("Attempting to read {0} bytes from the stream", + count - totalBytesRead); + int bytesRead = await stream.ReadAsync(buffer, + totalBytesRead, + count - totalBytesRead, + cancellationToken).ConfigureAwait(false); + if (bytesRead == 0) + { + Log("Unexpected -- read 0 bytes from the stream."); + throw new EndOfStreamException("Reached end of stream before end of read."); + } + Log("Read {0} bytes", bytesRead); + totalBytesRead += bytesRead; + } while (totalBytesRead < count); + Log("Finished read"); + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Razor.Tools/Roslyn/BuildProtocolUtil.cs b/src/Microsoft.AspNetCore.Razor.Tools/Roslyn/BuildProtocolUtil.cs new file mode 100644 index 0000000000..a4c521083a --- /dev/null +++ b/src/Microsoft.AspNetCore.Razor.Tools/Roslyn/BuildProtocolUtil.cs @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft. 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; +using System.Globalization; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading; +using Microsoft.CodeAnalysis.CommandLine; + +namespace Microsoft.CodeAnalysis.CompilerServer +{ + internal static class BuildProtocolUtil + { + internal static RunRequest GetRunRequest(BuildRequest req) + { + string currentDirectory; + string libDirectory; + string tempDirectory; + string[] arguments = GetCommandLineArguments(req, out currentDirectory, out tempDirectory, out libDirectory); + string language = ""; + switch (req.Language) + { + case RequestLanguage.CSharpCompile: + language = LanguageNames.CSharp; + break; + case RequestLanguage.VisualBasicCompile: + language = LanguageNames.VisualBasic; + break; + } + + return new RunRequest(language, currentDirectory, tempDirectory, libDirectory, arguments); + } + + internal static string[] GetCommandLineArguments(BuildRequest req, out string currentDirectory, out string tempDirectory, out string libDirectory) + { + currentDirectory = null; + libDirectory = null; + tempDirectory = null; + List commandLineArguments = new List(); + + foreach (BuildRequest.Argument arg in req.Arguments) + { + if (arg.ArgumentId == BuildProtocolConstants.ArgumentId.CurrentDirectory) + { + currentDirectory = arg.Value; + } + else if (arg.ArgumentId == BuildProtocolConstants.ArgumentId.TempDirectory) + { + tempDirectory = arg.Value; + } + else if (arg.ArgumentId == BuildProtocolConstants.ArgumentId.LibEnvVariable) + { + libDirectory = arg.Value; + } + else if (arg.ArgumentId == BuildProtocolConstants.ArgumentId.CommandLineArgument) + { + int argIndex = arg.ArgumentIndex; + while (argIndex >= commandLineArguments.Count) + commandLineArguments.Add(""); + commandLineArguments[argIndex] = arg.Value; + } + } + + return commandLineArguments.ToArray(); + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Razor.Tools/Roslyn/BuildServerConnection.cs b/src/Microsoft.AspNetCore.Razor.Tools/Roslyn/BuildServerConnection.cs new file mode 100644 index 0000000000..6b38bcf392 --- /dev/null +++ b/src/Microsoft.AspNetCore.Razor.Tools/Roslyn/BuildServerConnection.cs @@ -0,0 +1,518 @@ +// Copyright (c) Microsoft. 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.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.IO.Pipes; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Security.AccessControl; +using System.Security.Cryptography; +using System.Security.Principal; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Win32.SafeHandles; +using Roslyn.Utilities; +using static Microsoft.CodeAnalysis.CommandLine.CompilerServerLogger; +using static Microsoft.CodeAnalysis.CommandLine.NativeMethods; + +namespace Microsoft.CodeAnalysis.CommandLine +{ + internal struct BuildPathsAlt + { + /// + /// The path which contains the compiler binaries and response files. + /// + internal string ClientDirectory { get; } + + /// + /// The path in which the compilation takes place. + /// + internal string WorkingDirectory { get; } + + /// + /// The path which contains mscorlib. This can be null when specified by the user or running in a + /// CoreClr environment. + /// + internal string SdkDirectory { get; } + + /// + /// The temporary directory a compilation should use instead of . The latter + /// relies on global state individual compilations should ignore. + /// + internal string TempDirectory { get; } + + internal BuildPathsAlt(string clientDir, string workingDir, string sdkDir, string tempDir) + { + ClientDirectory = clientDir; + WorkingDirectory = workingDir; + SdkDirectory = sdkDir; + TempDirectory = tempDir; + } + } + + internal sealed class BuildServerConnection + { + internal const string ServerNameDesktop = "VBCSCompiler.exe"; + internal const string ServerNameCoreClr = "VBCSCompiler.dll"; + + // Spend up to 1s connecting to existing process (existing processes should be always responsive). + internal const int TimeOutMsExistingProcess = 1000; + + // Spend up to 20s connecting to a new process, to allow time for it to start. + internal const int TimeOutMsNewProcess = 20000; + + internal static async Task RunServerCompilationCore( + RequestLanguage language, + List arguments, + BuildPathsAlt buildPaths, + string pipeName, + string keepAlive, + string libEnvVariable, + int? timeoutOverride, + Func tryCreateServerFunc, + CancellationToken cancellationToken) + { + if (pipeName == null) + { + return new RejectedBuildResponse(); + } + + if (buildPaths.TempDirectory == null) + { + return new RejectedBuildResponse(); + } + + var clientDir = buildPaths.ClientDirectory; + var timeoutNewProcess = timeoutOverride ?? TimeOutMsNewProcess; + var timeoutExistingProcess = timeoutOverride ?? TimeOutMsExistingProcess; + var clientMutexName = GetClientMutexName(pipeName); + Task pipeTask = null; + using (var clientMutex = new Mutex(initiallyOwned: true, + name: clientMutexName, + createdNew: out var holdsMutex)) + { + try + { + if (!holdsMutex) + { + try + { + holdsMutex = clientMutex.WaitOne(timeoutNewProcess); + + if (!holdsMutex) + { + return new RejectedBuildResponse(); + } + } + catch (AbandonedMutexException) + { + holdsMutex = true; + } + } + + // Check for an already running server + var serverMutexName = GetServerMutexName(pipeName); + bool wasServerRunning = true; + var timeout = wasServerRunning ? timeoutExistingProcess : timeoutNewProcess; + + if (wasServerRunning || tryCreateServerFunc(clientDir, pipeName)) + { + pipeTask = TryConnectToServerAsync(pipeName, timeout, cancellationToken); + } + } + finally + { + if (holdsMutex) + { + clientMutex.ReleaseMutex(); + } + } + } + + if (pipeTask != null) + { + var pipe = await pipeTask.ConfigureAwait(false); + if (pipe != null) + { + var request = BuildRequest.Create(language, + buildPaths.WorkingDirectory, + buildPaths.TempDirectory, + arguments, + keepAlive, + libEnvVariable); + + return await TryCompile(pipe, request, cancellationToken).ConfigureAwait(false); + } + } + + return new RejectedBuildResponse(); + } + + /// + /// Try to compile using the server. Returns a null-containing Task if a response + /// from the server cannot be retrieved. + /// + private static async Task TryCompile(NamedPipeClientStream pipeStream, + BuildRequest request, + CancellationToken cancellationToken) + { + BuildResponse response; + using (pipeStream) + { + // Write the request + try + { + Log("Begin writing request"); + await request.WriteAsync(pipeStream, cancellationToken).ConfigureAwait(false); + Log("End writing request"); + } + catch (Exception e) + { + LogException(e, "Error writing build request."); + return new RejectedBuildResponse(); + } + + // Wait for the compilation and a monitor to detect if the server disconnects + var serverCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + Log("Begin reading response"); + + var responseTask = BuildResponse.ReadAsync(pipeStream, serverCts.Token); + var monitorTask = CreateMonitorDisconnectTask(pipeStream, "client", serverCts.Token); + await Task.WhenAny(responseTask, monitorTask).ConfigureAwait(false); + + Log("End reading response"); + + if (responseTask.IsCompleted) + { + // await the task to log any exceptions + try + { + response = await responseTask.ConfigureAwait(false); + } + catch (Exception e) + { + LogException(e, "Error reading response"); + response = new RejectedBuildResponse(); + } + } + else + { + Log("Server disconnect"); + response = new RejectedBuildResponse(); + } + + // Cancel whatever task is still around + serverCts.Cancel(); + Debug.Assert(response != null); + return response; + } + } + + /// + /// The IsConnected property on named pipes does not detect when the client has disconnected + /// if we don't attempt any new I/O after the client disconnects. We start an async I/O here + /// which serves to check the pipe for disconnection. + /// + internal static async Task CreateMonitorDisconnectTask( + PipeStream pipeStream, + string identifier = null, + CancellationToken cancellationToken = default(CancellationToken)) + { + var buffer = Array.Empty(); + + while (!cancellationToken.IsCancellationRequested && pipeStream.IsConnected) + { + // Wait a tenth of a second before trying again + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + + try + { + Log($"Before poking pipe {identifier}."); + await pipeStream.ReadAsync(buffer, 0, 0, cancellationToken).ConfigureAwait(false); + Log($"After poking pipe {identifier}."); + } + catch (OperationCanceledException) + { + } + catch (Exception e) + { + // It is okay for this call to fail. Errors will be reflected in the + // IsConnected property which will be read on the next iteration of the + LogException(e, $"Error poking pipe {identifier}."); + } + } + } + + /// + /// Connect to the pipe for a given directory and return it. + /// Throws on cancellation. + /// + /// Name of the named pipe to connect to. + /// Timeout to allow in connecting to process. + /// Cancellation token to cancel connection to server. + /// + /// An open to the server process or null on failure. + /// + internal static async Task TryConnectToServerAsync( + string pipeName, + int timeoutMs, + CancellationToken cancellationToken) + { + NamedPipeClientStream pipeStream; + try + { + // Machine-local named pipes are named "\\.\pipe\". + // We use the SHA1 of the directory the compiler exes live in as the pipe name. + // The NamedPipeClientStream class handles the "\\.\pipe\" part for us. + Log("Attempt to open named pipe '{0}'", pipeName); + + pipeStream = new NamedPipeClientStream(".", pipeName, PipeDirection.InOut, PipeOptions.Asynchronous); + cancellationToken.ThrowIfCancellationRequested(); + + Log("Attempt to connect named pipe '{0}'", pipeName); + try + { + await pipeStream.ConnectAsync(timeoutMs, cancellationToken).ConfigureAwait(false); + } + catch (Exception e) when (e is IOException || e is TimeoutException) + { + // Note: IOException can also indicate timeout. From docs: + // TimeoutException: Could not connect to the server within the + // specified timeout period. + // IOException: The server is connected to another client and the + // time-out period has expired. + + Log($"Connecting to server timed out after {timeoutMs} ms"); + return null; + } + Log("Named pipe '{0}' connected", pipeName); + + cancellationToken.ThrowIfCancellationRequested(); + + // Verify that we own the pipe. + if (!CheckPipeConnectionOwnership(pipeStream)) + { + Log("Owner of named pipe is incorrect"); + return null; + } + + return pipeStream; + } + catch (Exception e) when (!(e is TaskCanceledException || e is OperationCanceledException)) + { + LogException(e, "Exception while connecting to process"); + return null; + } + } + + internal static bool TryCreateServerCore(string clientDir, string pipeName) + { + bool isRunningOnCoreClr = true; + string expectedPath; + string processArguments; + if (isRunningOnCoreClr) + { + // The server should be in the same directory as the client + var expectedCompilerPath = Path.Combine(clientDir, ServerNameCoreClr); + expectedPath = Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") ?? "dotnet"; + processArguments = $@"""{expectedCompilerPath}"" ""-pipename:{pipeName}"""; + + if (!File.Exists(expectedCompilerPath)) + { + return false; + } + } + else + { + // The server should be in the same directory as the client + expectedPath = Path.Combine(clientDir, ServerNameDesktop); + processArguments = $@"""-pipename:{pipeName}"""; + + if (!File.Exists(expectedPath)) + { + return false; + } + } + + if (PlatformInformation.IsWindows) + { + // As far as I can tell, there isn't a way to use the Process class to + // create a process with no stdin/stdout/stderr, so we use P/Invoke. + // This code was taken from MSBuild task starting code. + + STARTUPINFO startInfo = new STARTUPINFO(); + startInfo.cb = Marshal.SizeOf(startInfo); + startInfo.hStdError = InvalidIntPtr; + startInfo.hStdInput = InvalidIntPtr; + startInfo.hStdOutput = InvalidIntPtr; + startInfo.dwFlags = STARTF_USESTDHANDLES; + uint dwCreationFlags = NORMAL_PRIORITY_CLASS | CREATE_NO_WINDOW; + + PROCESS_INFORMATION processInfo; + + Log("Attempting to create process '{0}'", expectedPath); + + var builder = new StringBuilder($@"""{expectedPath}"" {processArguments}"); + + bool success = CreateProcess( + lpApplicationName: null, + lpCommandLine: builder, + lpProcessAttributes: NullPtr, + lpThreadAttributes: NullPtr, + bInheritHandles: false, + dwCreationFlags: dwCreationFlags, + lpEnvironment: NullPtr, // Inherit environment + lpCurrentDirectory: clientDir, + lpStartupInfo: ref startInfo, + lpProcessInformation: out processInfo); + + if (success) + { + Log("Successfully created process with process id {0}", processInfo.dwProcessId); + CloseHandle(processInfo.hProcess); + CloseHandle(processInfo.hThread); + } + else + { + Log("Failed to create process. GetLastError={0}", Marshal.GetLastWin32Error()); + } + return success; + } + else + { + try + { + var startInfo = new ProcessStartInfo() + { + FileName = expectedPath, + Arguments = processArguments, + UseShellExecute = false, + WorkingDirectory = clientDir, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + Process.Start(startInfo); + return true; + } + catch + { + return false; + } + } + } + + /// + /// Check to ensure that the named pipe server we connected to is owned by the same + /// user. + /// + /// + /// The type is embedded in assemblies that need to run cross platform. While this particular + /// code will never be hit when running on non-Windows platforms it does need to work when + /// on Windows. To facilitate that we use reflection to make the check here to enable it to + /// compile into our cross plat assemblies. + /// + private static bool CheckPipeConnectionOwnership(NamedPipeClientStream pipeStream) + { + return true; + } + +#if NETSTANDARD1_3 + internal static bool CheckIdentityUnix(PipeStream stream) + { + // Identity verification is unavailable in the MSBuild task, + // but verification is not needed client-side so that's okay. + // (unavailable due to lack of internal reflection capabilities in netstandard1.3) + return true; + } +#else + [DllImport("System.Native", EntryPoint = "SystemNative_GetEUid")] + private static extern uint GetEUid(); + + [DllImport("System.Native", EntryPoint = "SystemNative_GetPeerID", SetLastError = true)] + private static extern int GetPeerID(SafeHandle socket, out uint euid); + + internal static bool CheckIdentityUnix(PipeStream stream) + { + var flags = BindingFlags.Instance | BindingFlags.NonPublic; + var handle = (SafePipeHandle)typeof(PipeStream).GetField("_handle", flags).GetValue(stream); + var handle2 = (SafeHandle)typeof(SafePipeHandle).GetField("_namedPipeSocketHandle", flags).GetValue(handle); + + uint myID = GetEUid(); + + if (GetPeerID(handle, out uint peerID) == -1) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + return myID == peerID; + } +#endif + + + internal static string GetServerMutexName(string pipeName) + { + return $"{pipeName}.server"; + } + + internal static string GetClientMutexName(string pipeName) + { + return $"{pipeName}.client"; + } + + /// + /// Gets the value of the temporary path for the current environment assuming the working directory + /// is . This function must emulate as + /// closely as possible. + /// + public static string GetTempPath(string workingDir) + { + if (PlatformInformation.IsUnix) + { + // Unix temp path is fine: it does not use the working directory + // (it uses ${TMPDIR} if set, otherwise, it returns /tmp) + return Path.GetTempPath(); + } + + var tmp = Environment.GetEnvironmentVariable("TMP"); + if (Path.IsPathRooted(tmp)) + { + return tmp; + } + + var temp = Environment.GetEnvironmentVariable("TEMP"); + if (Path.IsPathRooted(temp)) + { + return temp; + } + + if (!string.IsNullOrEmpty(workingDir)) + { + if (!string.IsNullOrEmpty(tmp)) + { + return Path.Combine(workingDir, tmp); + } + + if (!string.IsNullOrEmpty(temp)) + { + return Path.Combine(workingDir, temp); + } + } + + var userProfile = Environment.GetEnvironmentVariable("USERPROFILE"); + if (Path.IsPathRooted(userProfile)) + { + return userProfile; + } + + return Environment.GetEnvironmentVariable("SYSTEMROOT"); + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Razor.Tools/Roslyn/CommonCompiler.cs b/src/Microsoft.AspNetCore.Razor.Tools/Roslyn/CommonCompiler.cs new file mode 100644 index 0000000000..ccfbddc826 --- /dev/null +++ b/src/Microsoft.AspNetCore.Razor.Tools/Roslyn/CommonCompiler.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Microsoft.CodeAnalysis +{ + class CommonCompiler + { + internal const int Failed = 1; + internal const int Succeeded = 0; + } +} diff --git a/src/Microsoft.AspNetCore.Razor.Tools/Roslyn/CompilerRequestHandler.cs b/src/Microsoft.AspNetCore.Razor.Tools/Roslyn/CompilerRequestHandler.cs new file mode 100644 index 0000000000..855f5e5bb2 --- /dev/null +++ b/src/Microsoft.AspNetCore.Razor.Tools/Roslyn/CompilerRequestHandler.cs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft. 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.Collections.Immutable; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.CommandLine; + +using static Microsoft.CodeAnalysis.CommandLine.CompilerServerLogger; + +namespace Microsoft.CodeAnalysis.CompilerServer +{ + internal struct RunRequest + { + public string Language { get; } + public string CurrentDirectory { get; } + public string TempDirectory { get; } + public string LibDirectory { get; } + public string[] Arguments { get; } + + public RunRequest(string language, string currentDirectory, string tempDirectory, string libDirectory, string[] arguments) + { + Language = language; + CurrentDirectory = currentDirectory; + TempDirectory = tempDirectory; + LibDirectory = libDirectory; + Arguments = arguments; + } + } + + internal abstract class CompilerServerHost : ICompilerServerHost + { + public abstract IAnalyzerAssemblyLoader AnalyzerAssemblyLoader { get; } + + public abstract Func AssemblyReferenceProvider { get; } + + /// + /// Directory that contains the compiler executables and the response files. + /// + public string ClientDirectory { get; } + + /// + /// Directory that contains mscorlib. Can be null when the host is executing in a CoreCLR context. + /// + public string SdkDirectory { get; } + + protected CompilerServerHost(string clientDirectory, string sdkDirectory) + { + ClientDirectory = clientDirectory; + SdkDirectory = sdkDirectory; + } + + public abstract bool CheckAnalyzers(string baseDirectory, ImmutableArray analyzers); + + public bool TryCreateCompiler(RunRequest request, out CommonCompiler compiler) + { + compiler = null; + return false; + } + + public BuildResponse RunCompilation(RunRequest request, CancellationToken cancellationToken) + { + return null; + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Razor.Tools/Roslyn/CompilerServerLogger.cs b/src/Microsoft.AspNetCore.Razor.Tools/Roslyn/CompilerServerLogger.cs new file mode 100644 index 0000000000..d2871a8ba1 --- /dev/null +++ b/src/Microsoft.AspNetCore.Razor.Tools/Roslyn/CompilerServerLogger.cs @@ -0,0 +1,144 @@ +// Copyright (c) Microsoft. All Rights Reserved. Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Roslyn.Utilities; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; + +namespace Microsoft.CodeAnalysis.CommandLine +{ + /// + /// Class for logging information about what happens in the server and client parts of the + /// Roslyn command line compiler and build tasks. Useful for debugging what is going on. + /// + /// + /// To use the logging, set the environment variable RoslynCommandLineLogFile to the name + /// of a file to log to. This file is logged to by both client and server components. + /// + internal class CompilerServerLogger + { + // Environment variable, if set, to enable logging and set the file to log to. + private const string environmentVariable = "RoslynCommandLineLogFile"; + + private static readonly Stream s_loggingStream; + private static string s_prefix = "---"; + + /// + /// Static class initializer that initializes logging. + /// + static CompilerServerLogger() + { + s_loggingStream = null; + + try + { + // Check if the environment + string loggingFileName = Environment.GetEnvironmentVariable(environmentVariable); + + if (loggingFileName != null) + { + // If the environment variable contains the path of a currently existing directory, + // then use a process-specific name for the log file and put it in that directory. + // Otherwise, assume that the environment variable specifies the name of the log file. + if (Directory.Exists(loggingFileName)) + { + loggingFileName = Path.Combine(loggingFileName, $"server.{loggingFileName}.{GetCurrentProcessId()}.log"); + } + + // Open allowing sharing. We allow multiple processes to log to the same file, so we use share mode to allow that. + s_loggingStream = new FileStream(loggingFileName, FileMode.OpenOrCreate, FileAccess.Write, FileShare.ReadWrite); + } + } + catch (Exception e) + { + LogException(e, "Failed to create logging stream"); + } + } + + /// + /// Set the logging prefix that describes our role. + /// Typically a 3-letter abbreviation. If logging happens before this, it's logged with "---". + /// + public static void Initialize(string outputPrefix) + { + s_prefix = outputPrefix; + } + + /// + /// Log an exception. Also logs information about inner exceptions. + /// + public static void LogException(Exception e, string reason) + { + if (s_loggingStream != null) + { + Log("Exception '{0}' occurred during '{1}'. Stack trace:\r\n{2}", e.Message, reason, e.StackTrace); + + int innerExceptionLevel = 0; + + e = e.InnerException; + while (e != null) + { + Log("Inner exception[{0}] '{1}'. Stack trace: \r\n{1}", innerExceptionLevel, e.Message, e.StackTrace); + e = e.InnerException; + innerExceptionLevel += 1; + } + } + } + + /// + /// Log a line of text to the logging file, with string.Format arguments. + /// + public static void Log(string format, params object[] arguments) + { + if (s_loggingStream != null) + { + Log(string.Format(format, arguments)); + } + } + + /// + /// Log a line of text to the logging file. + /// + /// + public static void Log(string message) + { + if (s_loggingStream != null) + { + string prefix = GetLoggingPrefix(); + + string output = prefix + message + "\r\n"; + byte[] bytes = Encoding.UTF8.GetBytes(output); + + // Because multiple processes might be logging to the same file, we always seek to the end, + // write, and flush. + s_loggingStream.Seek(0, SeekOrigin.End); + s_loggingStream.Write(bytes, 0, bytes.Length); + s_loggingStream.Flush(); + } + } + + private static int GetCurrentProcessId() + { + var process = Process.GetCurrentProcess(); + return process.Id; + } + + private static int GetCurrentThreadId() + { + var thread = Thread.CurrentThread; + return thread.ManagedThreadId; + } + + /// + /// Get the string that prefixes all log entries. Shows the process, thread, and time. + /// + private static string GetLoggingPrefix() + { + return string.Format("{0} PID={1} TID={2} Ticks={3}: ", s_prefix, GetCurrentProcessId(), GetCurrentThreadId(), Environment.TickCount); + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Razor.Tools/Roslyn/DiagnosticListener.cs b/src/Microsoft.AspNetCore.Razor.Tools/Roslyn/DiagnosticListener.cs new file mode 100644 index 0000000000..a5c6bf3126 --- /dev/null +++ b/src/Microsoft.AspNetCore.Razor.Tools/Roslyn/DiagnosticListener.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft. 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 System.Text; +using System.Threading.Tasks; + +namespace Microsoft.CodeAnalysis.CompilerServer +{ + internal interface IDiagnosticListener + { + /// + /// Called when the server updates the keep alive value. + /// + void UpdateKeepAlive(TimeSpan timeSpan); + + /// + /// Called each time the server listens for new connections. + /// + void ConnectionListening(); + + /// + /// Called when a connection to the server occurs. + /// + void ConnectionReceived(); + + /// + /// Called when one or more connections have completed processing. The number of connections + /// processed is provided in . + /// + void ConnectionCompleted(int count); + + /// + /// Called when a bad client connection was detected and the server will be shutting down as a + /// result. + /// + void ConnectionRudelyEnded(); + + /// + /// Called when the server is shutting down because the keep alive timeout was reached. + /// + void KeepAliveReached(); + } + + internal sealed class EmptyDiagnosticListener : IDiagnosticListener + { + public void UpdateKeepAlive(TimeSpan timeSpan) + { + } + + public void ConnectionListening() + { + } + + public void ConnectionReceived() + { + } + + public void ConnectionCompleted(int count) + { + } + + public void ConnectionRudelyEnded() + { + } + + public void KeepAliveReached() + { + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Razor.Tools/Roslyn/ICompilerServerHost.cs b/src/Microsoft.AspNetCore.Razor.Tools/Roslyn/ICompilerServerHost.cs new file mode 100644 index 0000000000..710a86ba96 --- /dev/null +++ b/src/Microsoft.AspNetCore.Razor.Tools/Roslyn/ICompilerServerHost.cs @@ -0,0 +1,10 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Microsoft.CodeAnalysis.CompilerServer +{ + class ICompilerServerHost + { + } +} diff --git a/src/Microsoft.AspNetCore.Razor.Tools/Roslyn/NativeMethods.cs b/src/Microsoft.AspNetCore.Razor.Tools/Roslyn/NativeMethods.cs new file mode 100644 index 0000000000..f76afa8580 --- /dev/null +++ b/src/Microsoft.AspNetCore.Razor.Tools/Roslyn/NativeMethods.cs @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft. 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.Runtime.InteropServices; +using System.Text; + +namespace Microsoft.CodeAnalysis.CommandLine +{ + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + internal struct STARTUPINFO + { + internal Int32 cb; + internal string lpReserved; + internal string lpDesktop; + internal string lpTitle; + internal Int32 dwX; + internal Int32 dwY; + internal Int32 dwXSize; + internal Int32 dwYSize; + internal Int32 dwXCountChars; + internal Int32 dwYCountChars; + internal Int32 dwFillAttribute; + internal Int32 dwFlags; + internal Int16 wShowWindow; + internal Int16 cbReserved2; + internal IntPtr lpReserved2; + internal IntPtr hStdInput; + internal IntPtr hStdOutput; + internal IntPtr hStdError; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct PROCESS_INFORMATION + { + public IntPtr hProcess; + public IntPtr hThread; + public int dwProcessId; + public int dwThreadId; + } + + /// + /// Interop methods. + /// + internal static class NativeMethods + { + #region Constants + + internal static readonly IntPtr NullPtr = IntPtr.Zero; + internal static readonly IntPtr InvalidIntPtr = new IntPtr((int)-1); + + internal const uint NORMAL_PRIORITY_CLASS = 0x0020; + internal const uint CREATE_NO_WINDOW = 0x08000000; + internal const Int32 STARTF_USESTDHANDLES = 0x00000100; + internal const int ERROR_SUCCESS = 0; + + #endregion + + //------------------------------------------------------------------------------ + // CloseHandle + //------------------------------------------------------------------------------ + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool CloseHandle(IntPtr hObject); + + //------------------------------------------------------------------------------ + // CreateProcess + //------------------------------------------------------------------------------ + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool CreateProcess + ( + string lpApplicationName, + [In, Out]StringBuilder lpCommandLine, + IntPtr lpProcessAttributes, + IntPtr lpThreadAttributes, + [In, MarshalAs(UnmanagedType.Bool)] + bool bInheritHandles, + uint dwCreationFlags, + IntPtr lpEnvironment, + string lpCurrentDirectory, + [In] ref STARTUPINFO lpStartupInfo, + out PROCESS_INFORMATION lpProcessInformation + ); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + internal static extern IntPtr GetCommandLine(); + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Razor.Tools/Roslyn/PlatformInformation.cs b/src/Microsoft.AspNetCore.Razor.Tools/Roslyn/PlatformInformation.cs new file mode 100644 index 0000000000..476ee889ff --- /dev/null +++ b/src/Microsoft.AspNetCore.Razor.Tools/Roslyn/PlatformInformation.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft. All Rights Reserved. Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.IO; + +namespace Roslyn.Utilities +{ + /// + /// This class provides simple properties for determining whether the current platform is Windows or Unix-based. + /// We intentionally do not use System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(...) because + /// it incorrectly reports 'true' for 'Windows' in desktop builds running on Unix-based platforms via Mono. + /// + internal static class PlatformInformation + { + public static bool IsWindows => Path.DirectorySeparatorChar == '\\'; + public static bool IsUnix => Path.DirectorySeparatorChar == '/'; + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Razor.Tools/ServerCommand.cs b/src/Microsoft.AspNetCore.Razor.Tools/ServerCommand.cs new file mode 100644 index 0000000000..fb3cfb1fcc --- /dev/null +++ b/src/Microsoft.AspNetCore.Razor.Tools/ServerCommand.cs @@ -0,0 +1,58 @@ +// 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.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.CommandLineUtils; + +namespace Microsoft.AspNetCore.Razor.TagHelperTool +{ + internal class ServerCommand : CommandBase + { + public ServerCommand(Application parent) + : base(parent, "server") + { + Pipe = Option("-p|--pipe", "name of named pipe", CommandOptionType.SingleValue); + } + + public CommandOption Pipe { get; } + + protected override bool ValidateArguments() + { + if (string.IsNullOrEmpty(Pipe.Value())) + { + Pipe.Values.Add(PipeName.ComputeDefault()); + } + + return true; + } + + protected override Task ExecuteCoreAsync() + { + // Make sure there's only one server with the same identity at a time. + using (var mutex = new Mutex(initiallyOwned: true, name: MutexName.GetServerMutexName(Pipe.Value()), createdNew: out var holdsMutex)) + { + if (!holdsMutex) + { + // Another server is running, just exit. + Error.Write("Another server already running..."); + return Task.FromResult(1); + } + + try + { + var host = ConnectionHost.Create(Pipe.Value()); + var compilerHost = CompilerHost.Create(); + var dispatcher = RequestDispatcher.Create(host, compilerHost, Cancelled); + dispatcher.Run(); + } + finally + { + mutex.ReleaseMutex(); + } + } + + return Task.FromResult(0); + } + } +} diff --git a/src/Microsoft.AspNetCore.Razor.Tools/ShutdownCommand.cs b/src/Microsoft.AspNetCore.Razor.Tools/ShutdownCommand.cs new file mode 100644 index 0000000000..96313b7ed9 --- /dev/null +++ b/src/Microsoft.AspNetCore.Razor.Tools/ShutdownCommand.cs @@ -0,0 +1,90 @@ +// 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.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.CommandLine; +using Microsoft.Extensions.CommandLineUtils; + +namespace Microsoft.AspNetCore.Razor.TagHelperTool +{ + internal class ShutdownCommand : CommandBase + { + public ShutdownCommand(Application parent) + : base(parent, "shutdown") + { + Pipe = Option("-p|--pipe", "name of named pipe", CommandOptionType.SingleValue); + Wait = Option("-w|--wait", "wait for shutdown", CommandOptionType.NoValue); + } + + public CommandOption Pipe { get; } + + public CommandOption Wait { get; } + + protected override bool ValidateArguments() + { + if (string.IsNullOrEmpty(Pipe.Value())) + { + Pipe.Values.Add(PipeName.ComputeDefault()); + } + + return true; + } + + protected async override Task ExecuteCoreAsync() + { + if (!IsServerRunning()) + { + // server isn't running right now + Out.Write("Server is not running."); + return 0; + } + + try + { + using (var client = await Client.ConnectAsync(Pipe.Value(), timeout: null, cancellationToken: Cancelled)) + { + var request = BuildRequest.CreateShutdown(); + await request.WriteAsync(client.Stream, Cancelled).ConfigureAwait(false); + + var response = ((ShutdownBuildResponse)await BuildResponse.ReadAsync(client.Stream, Cancelled)); + + if (Wait.HasValue()) + { + try + { + var process = Process.GetProcessById(response.ServerProcessId); + process.WaitForExit(); + } + catch (Exception) + { + // There is an inherent race here with the server process. If it has already shutdown + // by the time we try to access it then the operation has succeed. + } + + Out.Write("Server pid:{0} shut down", response.ServerProcessId); + } + } + } + catch (Exception) when (IsServerRunning()) + { + // Ignore an exception that occurred while the server was shutting down. + } + + return 0; + } + + private bool IsServerRunning() + { + if (Mutex.TryOpenExisting(MutexName.GetServerMutexName(Pipe.Value()), out var mutex)) + { + mutex.Dispose(); + return true; + } + + return false; + } + } +}