From d2fe76be21ad1186b5edc61fa73f1fc634a75e3b Mon Sep 17 00:00:00 2001 From: Ajay Bhargav Baaskaran Date: Mon, 16 Apr 2018 17:33:44 -0700 Subject: [PATCH] Write process and pipe information to a pid file --- .../MetadataReaderExtensions.cs | 3 +- .../MutexName.cs | 6 +- .../ServerCommand.cs | 73 +++++++++++++++++++ .../ServerProtocol/NativeMethods.cs | 3 +- .../ServerProtocol/ServerConnection.cs | 18 +++-- .../ServerProtocol/ServerLogger.cs | 3 +- .../ShutdownCommand.cs | 7 +- .../BuildServerIntegrationTest.cs | 5 ++ .../BuildServerTestFixture.cs | 6 +- .../ServerCommandTest.cs | 69 ++++++++++++++++++ 10 files changed, 182 insertions(+), 11 deletions(-) create mode 100644 test/Microsoft.AspNetCore.Razor.Tools.Test/ServerCommandTest.cs diff --git a/src/Microsoft.AspNetCore.Razor.Tools/MetadataReaderExtensions.cs b/src/Microsoft.AspNetCore.Razor.Tools/MetadataReaderExtensions.cs index da1bddb865..17da6b63be 100644 --- a/src/Microsoft.AspNetCore.Razor.Tools/MetadataReaderExtensions.cs +++ b/src/Microsoft.AspNetCore.Razor.Tools/MetadataReaderExtensions.cs @@ -1,4 +1,5 @@ -// Copyright (c) Microsoft. All Rights Reserved. Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +// 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; diff --git a/src/Microsoft.AspNetCore.Razor.Tools/MutexName.cs b/src/Microsoft.AspNetCore.Razor.Tools/MutexName.cs index 367cf3b081..e12888080b 100644 --- a/src/Microsoft.AspNetCore.Razor.Tools/MutexName.cs +++ b/src/Microsoft.AspNetCore.Razor.Tools/MutexName.cs @@ -12,7 +12,11 @@ namespace Microsoft.AspNetCore.Razor.Tools public static string GetServerMutexName(string pipeName) { - return $"{pipeName}.server"; + // We want to prefix this with Global\ because we want this mutex to be visible + // across terminal sessions which is useful for cases like shutdown. + // https://msdn.microsoft.com/en-us/library/system.threading.mutex(v=vs.110).aspx#Remarks + // This still wouldn't allow other users to access the server because the pipe will fail to connect. + return $"Global\\{pipeName}.server"; } } } diff --git a/src/Microsoft.AspNetCore.Razor.Tools/ServerCommand.cs b/src/Microsoft.AspNetCore.Razor.Tools/ServerCommand.cs index 5a153eee80..44bb594b1c 100644 --- a/src/Microsoft.AspNetCore.Razor.Tools/ServerCommand.cs +++ b/src/Microsoft.AspNetCore.Razor.Tools/ServerCommand.cs @@ -2,6 +2,10 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Diagnostics; +using System.IO; +using System.Reflection; +using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.CommandLineUtils; @@ -17,6 +21,18 @@ namespace Microsoft.AspNetCore.Razor.Tools KeepAlive = Option("-k|--keep-alive", "sets the default idle timeout for the server in seconds", CommandOptionType.SingleValue); } + // For testing purposes only. + internal ServerCommand(Application parent, string pipeName, int? keepAlive = null) + : this(parent) + { + Pipe.Values.Add(pipeName); + + if (keepAlive.HasValue) + { + KeepAlive.Values.Add(keepAlive.Value.ToString()); + } + } + public CommandOption Pipe { get; } public CommandOption KeepAlive { get; } @@ -61,8 +77,20 @@ namespace Microsoft.AspNetCore.Razor.Tools return Task.FromResult(1); } + FileStream pidFileStream = null; try { + try + { + // Write the process and pipe information to a file in a well-known location. + pidFileStream = WritePidFile(); + } + catch (Exception ex) + { + // Something happened when trying to write to the pid file. Log and move on. + ServerLogger.LogException(ex, "Failed to create PID file."); + } + TimeSpan? keepAlive = null; if (KeepAlive.HasValue() && int.TryParse(KeepAlive.Value(), out var result)) { @@ -79,6 +107,7 @@ namespace Microsoft.AspNetCore.Razor.Tools { serverMutex.ReleaseMutex(); serverMutex.Dispose(); + pidFileStream?.Close(); } return Task.FromResult(0); @@ -89,5 +118,49 @@ namespace Microsoft.AspNetCore.Razor.Tools var dispatcher = RequestDispatcher.Create(host, compilerHost, cancellationToken, eventBus, keepAlive); dispatcher.Run(); } + + internal FileStream WritePidFile() + { + // To make all the running rzc servers more discoverable, We want to write the process Id and pipe name to a file. + // The file contents will be in the following format, + // + // + // rzc + // path/to/rzc.dll + // + + const int DefaultBufferSize = 4096; + var processId = Process.GetCurrentProcess().Id; + var fileName = $"rzc-{processId}"; + + var path = Environment.GetEnvironmentVariable("DOTNET_BUILD_PIDFILE_DIRECTORY"); + if (string.IsNullOrEmpty(path)) + { + var homeEnvVariable = PlatformInformation.IsWindows ? "USERPROFILE" : "HOME"; + var homePath = Environment.GetEnvironmentVariable(homeEnvVariable); + if (string.IsNullOrEmpty(homePath)) + { + // Couldn't locate the user profile directory. Bail. + return null; + } + + path = Path.Combine(homePath, ".dotnet", "pids", "build"); + } + + // Make sure the directory exists. + Directory.CreateDirectory(path); + + path = Path.Combine(path, fileName); + var fileStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read, DefaultBufferSize, FileOptions.DeleteOnClose); + + using (var writer = new StreamWriter(fileStream, Encoding.UTF8, DefaultBufferSize, leaveOpen: true)) + { + var rzcPath = Assembly.GetExecutingAssembly().Location; + var content = $"{processId}{Environment.NewLine}rzc{Environment.NewLine}{rzcPath}{Environment.NewLine}{Pipe.Value()}"; + writer.Write(content); + } + + return fileStream; + } } } diff --git a/src/Microsoft.AspNetCore.Razor.Tools/ServerProtocol/NativeMethods.cs b/src/Microsoft.AspNetCore.Razor.Tools/ServerProtocol/NativeMethods.cs index 00f0610842..73a7363ecb 100644 --- a/src/Microsoft.AspNetCore.Razor.Tools/ServerProtocol/NativeMethods.cs +++ b/src/Microsoft.AspNetCore.Razor.Tools/ServerProtocol/NativeMethods.cs @@ -1,4 +1,5 @@ -// Copyright (c) Microsoft. All Rights Reserved. Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +// 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.Runtime.InteropServices; diff --git a/src/Microsoft.AspNetCore.Razor.Tools/ServerProtocol/ServerConnection.cs b/src/Microsoft.AspNetCore.Razor.Tools/ServerProtocol/ServerConnection.cs index 1bd9a1365b..2583d1e0dc 100644 --- a/src/Microsoft.AspNetCore.Razor.Tools/ServerProtocol/ServerConnection.cs +++ b/src/Microsoft.AspNetCore.Razor.Tools/ServerProtocol/ServerConnection.cs @@ -1,4 +1,5 @@ -// Copyright (c) Microsoft. All Rights Reserved. Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +// 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; @@ -21,6 +22,9 @@ namespace Microsoft.AspNetCore.Razor.Tools // Spend up to 20s connecting to a new process, to allow time for it to start. private const int TimeOutMsNewProcess = 20000; + // Custom delegate that contains an out param to use with TryCreateServerCore method. + private delegate TResult TryCreateServerCoreDelegate(T1 arg1, T2 arg2, out T3 arg3, T4 arg4); + public static bool WasServerMutexOpen(string mutexName) { Mutex mutex = null; @@ -118,7 +122,7 @@ namespace Microsoft.AspNetCore.Razor.Tools string pipeName, string keepAlive, int? timeoutOverride, - Func tryCreateServerFunc, + TryCreateServerCoreDelegate tryCreateServerFunc, CancellationToken cancellationToken, bool debug) { @@ -181,7 +185,7 @@ namespace Microsoft.AspNetCore.Razor.Tools var wasServerRunning = WasServerMutexOpen(serverMutexName); var timeout = wasServerRunning ? timeoutExistingProcess : timeoutNewProcess; - if (wasServerRunning || tryCreateServerFunc(clientDir, pipeName, debug)) + if (wasServerRunning || tryCreateServerFunc(clientDir, pipeName, out var _, debug)) { pipeTask = Client.ConnectAsync(pipeName, TimeSpan.FromMilliseconds(timeout), cancellationToken); } @@ -277,10 +281,11 @@ namespace Microsoft.AspNetCore.Razor.Tools } // Internal for testing. - internal static bool TryCreateServerCore(string clientDir, string pipeName, bool debug = false) + internal static bool TryCreateServerCore(string clientDir, string pipeName, out int? processId, bool debug = false) { string expectedPath; string processArguments; + processId = null; // The server should be in the same directory as the client var expectedCompilerPath = Path.Combine(clientDir, ServerName); @@ -328,6 +333,7 @@ namespace Microsoft.AspNetCore.Razor.Tools ServerLogger.Log("Successfully created process with process id {0}", processInfo.dwProcessId); NativeMethods.CloseHandle(processInfo.hProcess); NativeMethods.CloseHandle(processInfo.hThread); + processId = processInfo.dwProcessId; } else { @@ -351,7 +357,9 @@ namespace Microsoft.AspNetCore.Razor.Tools CreateNoWindow = true }; - Process.Start(startInfo); + var process = Process.Start(startInfo); + processId = process.Id; + return true; } catch diff --git a/src/Microsoft.AspNetCore.Razor.Tools/ServerProtocol/ServerLogger.cs b/src/Microsoft.AspNetCore.Razor.Tools/ServerProtocol/ServerLogger.cs index 21ff3a06a9..be2e934160 100644 --- a/src/Microsoft.AspNetCore.Razor.Tools/ServerProtocol/ServerLogger.cs +++ b/src/Microsoft.AspNetCore.Razor.Tools/ServerProtocol/ServerLogger.cs @@ -1,4 +1,5 @@ -// Copyright (c) Microsoft. All Rights Reserved. Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +// 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; diff --git a/src/Microsoft.AspNetCore.Razor.Tools/ShutdownCommand.cs b/src/Microsoft.AspNetCore.Razor.Tools/ShutdownCommand.cs index f545574b6f..443f454f77 100644 --- a/src/Microsoft.AspNetCore.Razor.Tools/ShutdownCommand.cs +++ b/src/Microsoft.AspNetCore.Razor.Tools/ShutdownCommand.cs @@ -44,8 +44,13 @@ namespace Microsoft.AspNetCore.Razor.Tools try { - using (var client = await Client.ConnectAsync(Pipe.Value(), timeout: null, cancellationToken: Cancelled)) + using (var client = await Client.ConnectAsync(Pipe.Value(), timeout: TimeSpan.FromSeconds(5), cancellationToken: Cancelled)) { + if (client == null) + { + throw new InvalidOperationException("Couldn't connect to the server."); + } + var request = ServerRequest.CreateShutdown(); await request.WriteAsync(client.Stream, Cancelled).ConfigureAwait(false); diff --git a/test/Microsoft.AspNetCore.Razor.Design.Test/IntegrationTests/BuildServerIntegrationTest.cs b/test/Microsoft.AspNetCore.Razor.Design.Test/IntegrationTests/BuildServerIntegrationTest.cs index a5727d54dd..478ee4fee6 100644 --- a/test/Microsoft.AspNetCore.Razor.Design.Test/IntegrationTests/BuildServerIntegrationTest.cs +++ b/test/Microsoft.AspNetCore.Razor.Design.Test/IntegrationTests/BuildServerIntegrationTest.cs @@ -1,6 +1,8 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System; +using System.Diagnostics; using System.IO; using System.Threading; using System.Threading.Tasks; @@ -15,9 +17,12 @@ namespace Microsoft.AspNetCore.Razor.Design.IntegrationTests { public class BuildServerIntegrationTest : MSBuildIntegrationTestBase, IClassFixture { + private BuildServerTestFixture _buildServer; + public BuildServerIntegrationTest(BuildServerTestFixture buildServer) : base(buildServer) { + _buildServer = buildServer; } [Fact] diff --git a/test/Microsoft.AspNetCore.Razor.Design.Test/IntegrationTests/BuildServerTestFixture.cs b/test/Microsoft.AspNetCore.Razor.Design.Test/IntegrationTests/BuildServerTestFixture.cs index 3abc66a0eb..ea18e6cfa0 100644 --- a/test/Microsoft.AspNetCore.Razor.Design.Test/IntegrationTests/BuildServerTestFixture.cs +++ b/test/Microsoft.AspNetCore.Razor.Design.Test/IntegrationTests/BuildServerTestFixture.cs @@ -18,14 +18,18 @@ namespace Microsoft.AspNetCore.Razor.Design.IntegrationTests { PipeName = Guid.NewGuid().ToString(); - if (!ServerConnection.TryCreateServerCore(Environment.CurrentDirectory, PipeName)) + if (!ServerConnection.TryCreateServerCore(Environment.CurrentDirectory, PipeName, out var processId)) { throw new InvalidOperationException($"Failed to start the build server at pipe {PipeName}."); } + + ProcessId = processId; } public string PipeName { get; } + public int? ProcessId { get; } + public void Dispose() { // Shutdown the build server. diff --git a/test/Microsoft.AspNetCore.Razor.Tools.Test/ServerCommandTest.cs b/test/Microsoft.AspNetCore.Razor.Tools.Test/ServerCommandTest.cs new file mode 100644 index 0000000000..1acae3ef23 --- /dev/null +++ b/test/Microsoft.AspNetCore.Razor.Tools.Test/ServerCommandTest.cs @@ -0,0 +1,69 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Diagnostics; +using System.IO; +using System.Threading; +using Microsoft.CodeAnalysis; +using Moq; +using Xunit; + +namespace Microsoft.AspNetCore.Razor.Tools +{ + public class ServerCommandTest + { + [Fact] + public void WritePidFile_WorksAsExpected() + { + // Arrange + var expectedProcessId = Process.GetCurrentProcess().Id; + var expectedRzcPath = typeof(ServerCommand).Assembly.Location; + var expectedFileName = $"rzc-{expectedProcessId}"; + var homeEnvVariable = PlatformInformation.IsWindows ? "USERPROFILE" : "HOME"; + var path = Path.Combine(Environment.GetEnvironmentVariable(homeEnvVariable), ".dotnet", "pids", "build", expectedFileName); + + var pipeName = Guid.NewGuid().ToString(); + var server = GetServerCommand(pipeName); + + // Act & Assert + try + { + using (var _ = server.WritePidFile()) + { + Assert.True(File.Exists(path)); + + // Make sure another stream can be opened while the write stream is still open. + using (var fileStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Write | FileShare.Delete)) + using (var reader = new StreamReader(fileStream)) + { + var lines = reader.ReadToEnd().Split(Environment.NewLine); + Assert.Equal(new[] { expectedProcessId.ToString(), "rzc", expectedRzcPath, pipeName }, lines); + } + } + + // Make sure the file is deleted on dispose. + Assert.False(File.Exists(path)); + } + finally + { + // Delete the file in case the test fails. + if (File.Exists(path)) + { + File.Delete(path); + } + } + } + + private ServerCommand GetServerCommand(string pipeName) + { + var application = new Application( + CancellationToken.None, + Mock.Of(), + Mock.Of(), + (path, properties) => MetadataReference.CreateFromFile(path, properties)); + + return new ServerCommand(application, pipeName); + } + } +}