using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; namespace Microsoft.AspNetCore.NodeServices.HostingModels { /// /// Class responsible for launching a Node child process on the local machine, determining when it is ready to /// accept invocations, detecting if it dies on its own, and finally terminating it on disposal. /// /// This abstract base class uses the input/output streams of the child process to perform a simple handshake /// to determine when the child process is ready to accept invocations. This is agnostic to the mechanism that /// derived classes use to actually perform the invocations (e.g., they could use HTTP-RPC, or a binary TCP /// protocol, or any other RPC-type mechanism). /// /// public abstract class OutOfProcessNodeInstance : INodeInstance { /// /// The to which the Node.js instance's stdout/stderr is being redirected. /// protected readonly ILogger OutputLogger; private const string ConnectionEstablishedMessage = "[Microsoft.AspNetCore.NodeServices:Listening]"; private readonly TaskCompletionSource _connectionIsReadySource = new TaskCompletionSource(); private bool _disposed; private readonly StringAsTempFile _entryPointScript; private FileSystemWatcher _fileSystemWatcher; private int _invocationTimeoutMilliseconds; private bool _launchWithDebugging; private readonly Process _nodeProcess; private int? _nodeDebuggingPort; private bool _nodeProcessNeedsRestart; private readonly string[] _watchFileExtensions; /// /// Creates a new instance of . /// /// The path to the entry point script that the Node instance should load and execute. /// The root path of the current project. This is used when resolving Node.js module paths relative to the project root. /// The filename extensions that should be watched within the project root. The Node instance will automatically shut itself down if any matching file changes. /// Additional command-line arguments to be passed to the Node.js instance. /// A token that indicates when the host application is stopping. /// The to which the Node.js instance's stdout/stderr (and other log information) should be written. /// Environment variables to be set on the Node.js process. /// The maximum duration, in milliseconds, to wait for RPC calls to complete. /// If true, passes a flag to the Node.js process telling it to accept V8 debugger connections. /// If debugging is enabled, the Node.js process should listen for V8 debugger connections on this port. public OutOfProcessNodeInstance( string entryPointScript, string projectPath, string[] watchFileExtensions, string commandLineArguments, CancellationToken applicationStoppingToken, ILogger nodeOutputLogger, IDictionary environmentVars, int invocationTimeoutMilliseconds, bool launchWithDebugging, int debuggingPort) { if (nodeOutputLogger == null) { throw new ArgumentNullException(nameof(nodeOutputLogger)); } OutputLogger = nodeOutputLogger; _entryPointScript = new StringAsTempFile(entryPointScript, applicationStoppingToken); _invocationTimeoutMilliseconds = invocationTimeoutMilliseconds; _launchWithDebugging = launchWithDebugging; var startInfo = PrepareNodeProcessStartInfo(_entryPointScript.FileName, projectPath, commandLineArguments, environmentVars, _launchWithDebugging, debuggingPort); _nodeProcess = LaunchNodeProcess(startInfo); _watchFileExtensions = watchFileExtensions; _fileSystemWatcher = BeginFileWatcher(projectPath); ConnectToInputOutputStreams(); } /// /// Asynchronously invokes code in the Node.js instance. /// /// The JSON-serializable data type that the Node.js code will asynchronously return. /// A that can be used to cancel the invocation. /// The path to the Node.js module (i.e., JavaScript file) relative to your project root that contains the code to be invoked. /// If set, specifies the CommonJS export to be invoked. If not set, the module's default CommonJS export itself must be a function to be invoked. /// Any sequence of JSON-serializable arguments to be passed to the Node.js function. /// A representing the completion of the RPC call. public async Task InvokeExportAsync( CancellationToken cancellationToken, string moduleName, string exportNameOrNull, params object[] args) { if (_nodeProcess.HasExited || _nodeProcessNeedsRestart) { // This special kind of exception triggers a transparent retry - NodeServicesImpl will launch // a new Node instance and pass the invocation to that one instead. // Note that if the Node process is listening for debugger connections, then we need it to shut // down immediately and not stay open for connection draining (because if it did, the new Node // instance wouldn't able to start, because the old one would still hold the debugging port). var message = _nodeProcess.HasExited ? "The Node process has exited" : "The Node process needs to restart"; throw new NodeInvocationException( message, details: null, nodeInstanceUnavailable: true, allowConnectionDraining: !_launchWithDebugging); } // Construct a new cancellation token that combines the supplied token with the configured invocation // timeout. Technically we could avoid wrapping the cancellationToken if no timeout is configured, // but that's not really a major use case, since timeouts are enabled by default. using (var timeoutSource = new CancellationTokenSource()) using (var combinedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutSource.Token)) { if (_invocationTimeoutMilliseconds > 0) { timeoutSource.CancelAfter(_invocationTimeoutMilliseconds); } // By overwriting the supplied cancellation token, we ensure that it isn't accidentally used // below. We only want to pass through the token that respects timeouts. cancellationToken = combinedCancellationTokenSource.Token; var connectionDidSucceed = false; try { // Wait until the connection is established. This will throw if the connection fails to initialize, // or if cancellation is requested first. Note that we can't really cancel the "establishing connection" // task because that's shared with all callers, but we can stop waiting for it if this call is cancelled. await _connectionIsReadySource.Task.OrThrowOnCancellation(cancellationToken); connectionDidSucceed = true; return await InvokeExportAsync(new NodeInvocationInfo { ModuleName = moduleName, ExportedFunctionName = exportNameOrNull, Args = args }, cancellationToken); } catch (TaskCanceledException) { if (timeoutSource.IsCancellationRequested) { // It was very common for developers to report 'TaskCanceledException' when encountering almost any // trouble when using NodeServices. Now we have a default invocation timeout, and attempt to give // a more descriptive exception message if it happens. if (!connectionDidSucceed) { // This is very unlikely, but for debugging, it's still useful to differentiate it from the // case below. throw new NodeInvocationException( $"Attempt to connect to Node timed out after {_invocationTimeoutMilliseconds}ms.", string.Empty); } else { // Developers encounter this fairly often (if their Node code fails without invoking the callback, // all that the .NET side knows is that the invocation eventually times out). Previously, this surfaced // as a TaskCanceledException, but this led to a lot of issue reports. Now we throw the following // descriptive error. throw new NodeInvocationException( $"The Node invocation timed out after {_invocationTimeoutMilliseconds}ms.", $"You can change the timeout duration by setting the {NodeServicesOptions.TimeoutConfigPropertyName} " + $"property on {nameof(NodeServicesOptions)}.\n\n" + "The first debugging step is to ensure that your Node.js function always invokes the supplied " + "callback (or throws an exception synchronously), even if it encounters an error. Otherwise, " + "the .NET code has no way to know that it is finished or has failed." ); } } else { throw; } } } } /// /// Disposes this instance. /// public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } /// /// Asynchronously invokes code in the Node.js instance. /// /// The JSON-serializable data type that the Node.js code will asynchronously return. /// Specifies the Node.js function to be invoked and arguments to be passed to it. /// A that can be used to cancel the invocation. /// A representing the completion of the RPC call. protected abstract Task InvokeExportAsync( NodeInvocationInfo invocationInfo, CancellationToken cancellationToken); /// /// Configures a instance describing how to launch the Node.js process. /// /// The entrypoint JavaScript file that the Node.js process should execute. /// The root path of the project. This is used when locating Node.js modules relative to the project root. /// Command-line arguments to be passed to the Node.js process. /// Environment variables to be set on the Node.js process. /// If true, passes a flag to the Node.js process telling it to accept V8 Inspector connections. /// If debugging is enabled, the Node.js process should listen for V8 Inspector connections on this port. /// protected virtual ProcessStartInfo PrepareNodeProcessStartInfo( string entryPointFilename, string projectPath, string commandLineArguments, IDictionary environmentVars, bool launchWithDebugging, int debuggingPort) { // This method is virtual, as it provides a way to override the NODE_PATH or the path to node.exe string debuggingArgs; if (launchWithDebugging) { debuggingArgs = debuggingPort != default(int) ? $"--inspect={debuggingPort} " : "--inspect "; _nodeDebuggingPort = debuggingPort; } else { debuggingArgs = string.Empty; } var thisProcessPid = Process.GetCurrentProcess().Id; var startInfo = new ProcessStartInfo("node") { Arguments = $"{debuggingArgs}\"{entryPointFilename}\" --parentPid {thisProcessPid} {commandLineArguments ?? string.Empty}", UseShellExecute = false, RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true, WorkingDirectory = projectPath }; // Append environment vars if (environmentVars != null) { foreach (var envVarKey in environmentVars.Keys) { var envVarValue = environmentVars[envVarKey]; if (envVarValue != null) { SetEnvironmentVariable(startInfo, envVarKey, envVarValue); } } } // Append projectPath to NODE_PATH so it can locate node_modules var existingNodePath = Environment.GetEnvironmentVariable("NODE_PATH") ?? string.Empty; if (existingNodePath != string.Empty) { existingNodePath += Path.PathSeparator; } var nodePathValue = existingNodePath + Path.Combine(projectPath, "node_modules"); SetEnvironmentVariable(startInfo, "NODE_PATH", nodePathValue); return startInfo; } /// /// Virtual method invoked whenever the Node.js process emits a line to its stdout. /// /// The line emitted to the Node.js process's stdout. protected virtual void OnOutputDataReceived(string outputData) { OutputLogger.LogInformation(outputData); } /// /// Virtual method invoked whenever the Node.js process emits a line to its stderr. /// /// The line emitted to the Node.js process's stderr. protected virtual void OnErrorDataReceived(string errorData) { OutputLogger.LogError(errorData); } /// /// Disposes the instance. /// /// True if the object is disposing or false if it is finalizing. protected virtual void Dispose(bool disposing) { if (!_disposed) { if (disposing) { _entryPointScript.Dispose(); EnsureFileSystemWatcherIsDisposed(); } // Make sure the Node process is finished // TODO: Is there a more graceful way to end it? Or does this still let it perform any cleanup? if (_nodeProcess != null && !_nodeProcess.HasExited) { _nodeProcess.Kill(); } _disposed = true; } } private void EnsureFileSystemWatcherIsDisposed() { if (_fileSystemWatcher != null) { _fileSystemWatcher.Dispose(); _fileSystemWatcher = null; } } private static void SetEnvironmentVariable(ProcessStartInfo startInfo, string name, string value) { startInfo.Environment[name] = value; } private static Process LaunchNodeProcess(ProcessStartInfo startInfo) { try { var process = Process.Start(startInfo); // On Mac at least, a killed child process is left open as a zombie until the parent // captures its exit code. We don't need the exit code for this process, and don't want // to use process.WaitForExit() explicitly (we'd have to block the thread until it really // has exited), but we don't want to leave zombies lying around either. It's sufficient // to use process.EnableRaisingEvents so that .NET will grab the exit code and let the // zombie be cleaned away without having to block our thread. process.EnableRaisingEvents = true; return process; } catch (Exception ex) { var message = "Failed to start Node process. To resolve this:.\n\n" + "[1] Ensure that Node.js is installed and can be found in one of the PATH directories.\n" + $" Current PATH enviroment variable is: { Environment.GetEnvironmentVariable("PATH") }\n" + " Make sure the Node executable is in one of those directories, or update your PATH.\n\n" + "[2] See the InnerException for further details of the cause."; throw new InvalidOperationException(message, ex); } } private static string UnencodeNewlines(string str) { if (str != null) { // The token here needs to match the const in OverrideStdOutputs.ts. // See the comment there for why we're doing this. str = str.Replace("__ns_newline__", Environment.NewLine); } return str; } private void ConnectToInputOutputStreams() { var initializationIsCompleted = false; _nodeProcess.OutputDataReceived += (sender, evt) => { if (evt.Data == ConnectionEstablishedMessage && !initializationIsCompleted) { _connectionIsReadySource.SetResult(null); initializationIsCompleted = true; } else if (evt.Data != null) { OnOutputDataReceived(UnencodeNewlines(evt.Data)); } }; _nodeProcess.ErrorDataReceived += (sender, evt) => { if (evt.Data != null) { if (_launchWithDebugging && IsDebuggerMessage(evt.Data)) { OutputLogger.LogWarning(evt.Data); } else { OnErrorDataReceived(UnencodeNewlines(evt.Data)); } } }; _nodeProcess.BeginOutputReadLine(); _nodeProcess.BeginErrorReadLine(); } private static bool IsDebuggerMessage(string message) { return message.StartsWith("Debugger attached", StringComparison.Ordinal) || message.StartsWith("Debugger listening ", StringComparison.Ordinal) || message.StartsWith("To start debugging", StringComparison.Ordinal) || message.Equals("Warning: This is an experimental feature and could change at any time.", StringComparison.Ordinal) || message.Equals("For help see https://nodejs.org/en/docs/inspector", StringComparison.Ordinal) || message.Contains("chrome-devtools:"); } private FileSystemWatcher BeginFileWatcher(string rootDir) { if (_watchFileExtensions == null || _watchFileExtensions.Length == 0) { // Nothing to watch return null; } var watcher = new FileSystemWatcher(rootDir) { IncludeSubdirectories = true, NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.DirectoryName }; watcher.Changed += OnFileChanged; watcher.Created += OnFileChanged; watcher.Deleted += OnFileChanged; watcher.Renamed += OnFileRenamed; watcher.EnableRaisingEvents = true; return watcher; } private void OnFileChanged(object source, FileSystemEventArgs e) { if (IsFilenameBeingWatched(e.FullPath)) { RestartDueToFileChange(e.FullPath); } } private void OnFileRenamed(object source, RenamedEventArgs e) { if (IsFilenameBeingWatched(e.OldFullPath) || IsFilenameBeingWatched(e.FullPath)) { RestartDueToFileChange(e.OldFullPath); } } private bool IsFilenameBeingWatched(string fullPath) { if (string.IsNullOrEmpty(fullPath)) { return false; } else { var actualExtension = Path.GetExtension(fullPath) ?? string.Empty; return _watchFileExtensions.Any(actualExtension.Equals); } } private void RestartDueToFileChange(string fullPath) { OutputLogger.LogInformation($"Node will restart because file changed: {fullPath}"); _nodeProcessNeedsRestart = true; // There's no need to watch for any more changes, since we're already restarting, and if the // restart takes some time (e.g., due to connection draining), we could end up getting duplicate // notifications. EnsureFileSystemWatcherIsDisposed(); } /// /// Implements the finalization part of the IDisposable pattern by calling Dispose(false). /// ~OutOfProcessNodeInstance() { Dispose(false); } } }