Skip to content

Commit a19e37f

Browse files
Move logic for restarting Node child process into NodeServicesImpl. Tidy up lots.
1 parent 4fb3b18 commit a19e37f

File tree

8 files changed

+225
-186
lines changed

8 files changed

+225
-186
lines changed

src/Microsoft.AspNetCore.NodeServices/Configuration/Configuration.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ private static INodeInstance CreateNodeInstance(NodeServicesOptions options)
4848
case NodeHostingModel.Http:
4949
return new HttpNodeInstance(options.ProjectPath, /* port */ 0, options.WatchFileExtensions);
5050
case NodeHostingModel.Socket:
51-
return new SocketNodeInstance(options.ProjectPath, options.WatchFileExtensions);
51+
var pipeName = "pni-" + Guid.NewGuid().ToString("D"); // Arbitrary non-clashing string
52+
return new SocketNodeInstance(options.ProjectPath, options.WatchFileExtensions, pipeName);
5253
default:
5354
throw new ArgumentException("Unknown hosting model: " + options.HostingModel);
5455
}

src/Microsoft.AspNetCore.NodeServices/Content/Node/entrypoint-socket.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@
176176
// Begin listening now. The underlying transport varies according to the runtime platform.
177177
// On Windows it's Named Pipes; on Linux/OSX it's Domain Sockets.
178178
var useWindowsNamedPipes = /^win/.test(process.platform);
179-
var listenAddress = (useWindowsNamedPipes ? '\\\\.\\pipe\\' : '/tmp/') + parsedArgs.pipename;
179+
var listenAddress = (useWindowsNamedPipes ? '\\\\.\\pipe\\' : '/tmp/') + parsedArgs.listenAddress;
180180
server.listen(listenAddress);
181181

182182

src/Microsoft.AspNetCore.NodeServices/HostingModels/HttpNodeInstance.cs

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,18 @@
99

1010
namespace Microsoft.AspNetCore.NodeServices.HostingModels
1111
{
12+
/// <summary>
13+
/// A specialisation of the OutOfProcessNodeInstance base class that uses HTTP to perform RPC invocations.
14+
///
15+
/// The Node child process starts an HTTP listener on an arbitrary available port (except where a nonzero
16+
/// port number is specified as a constructor parameter), and signals which port was selected using the same
17+
/// input/output-based mechanism that the base class uses to determine when the child process is ready to
18+
/// accept RPC invocations.
19+
///
20+
/// TODO: Remove the file-watching logic from here and centralise it in OutOfProcessNodeInstance, implementing
21+
/// the actual watching in .NET code (not Node), for consistency across platforms.
22+
/// </summary>
23+
/// <seealso cref="Microsoft.AspNetCore.NodeServices.HostingModels.OutOfProcessNodeInstance" />
1224
internal class HttpNodeInstance : OutOfProcessNodeInstance
1325
{
1426
private static readonly Regex PortMessageRegex =
@@ -19,7 +31,7 @@ internal class HttpNodeInstance : OutOfProcessNodeInstance
1931
ContractResolver = new CamelCasePropertyNamesContractResolver()
2032
};
2133

22-
private HttpClient _client;
34+
private readonly HttpClient _client;
2335
private bool _disposed;
2436
private int _portNumber;
2537

@@ -47,9 +59,6 @@ private static string MakeCommandLineOptions(int port, string[] watchFileExtensi
4759

4860
protected override async Task<T> InvokeExportAsync<T>(NodeInvocationInfo invocationInfo)
4961
{
50-
await EnsureReady();
51-
52-
// TODO: Use System.Net.Http.Formatting (PostAsJsonAsync etc.)
5362
var payloadJson = JsonConvert.SerializeObject(invocationInfo, JsonSerializerSettings);
5463
var payload = new StringContent(payloadJson, Encoding.UTF8, "application/json");
5564
var response = await _client.PostAsync("http://localhost:" + _portNumber, payload);
@@ -97,6 +106,9 @@ protected override async Task<T> InvokeExportAsync<T>(NodeInvocationInfo invocat
97106

98107
protected override void OnOutputDataReceived(string outputData)
99108
{
109+
// Watch for "port selected" messages, and when observed, store the port number
110+
// so we can use it when making HTTP requests. The child process will always send
111+
// one of these messages before it sends a "ready for connections" message.
100112
var match = _portNumber != 0 ? null : PortMessageRegex.Match(outputData);
101113
if (match != null && match.Success)
102114
{
@@ -108,12 +120,6 @@ protected override void OnOutputDataReceived(string outputData)
108120
}
109121
}
110122

111-
protected override void OnBeforeLaunchProcess()
112-
{
113-
// Prepare to receive a new port number
114-
_portNumber = 0;
115-
}
116-
117123
protected override void Dispose(bool disposing) {
118124
base.Dispose(disposing);
119125

src/Microsoft.AspNetCore.NodeServices/HostingModels/NodeInvocationException.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,17 @@ namespace Microsoft.AspNetCore.NodeServices.HostingModels
44
{
55
public class NodeInvocationException : Exception
66
{
7+
public bool NodeInstanceUnavailable { get; private set; }
8+
79
public NodeInvocationException(string message, string details)
810
: base(message + Environment.NewLine + details)
911
{
1012
}
13+
14+
public NodeInvocationException(string message, string details, bool nodeInstanceUnavailable)
15+
: this(message, details)
16+
{
17+
NodeInstanceUnavailable = nodeInstanceUnavailable;
18+
}
1119
}
1220
}

src/Microsoft.AspNetCore.NodeServices/HostingModels/OutOfProcessNodeInstance.cs

Lines changed: 77 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -6,37 +6,43 @@
66
namespace Microsoft.AspNetCore.NodeServices.HostingModels
77
{
88
/// <summary>
9-
/// Class responsible for launching the Node child process, determining when it is ready to accept invocations,
10-
/// and finally killing it when the parent process exits. Also it restarts the child process if it dies.
9+
/// Class responsible for launching a Node child process on the local machine, determining when it is ready to
10+
/// accept invocations, detecting if it dies on its own, and finally terminating it on disposal.
11+
///
12+
/// This abstract base class uses the input/output streams of the child process to perform a simple handshake
13+
/// to determine when the child process is ready to accept invocations. This is agnostic to the mechanism that
14+
/// derived classes use to actually perform the invocations (e.g., they could use HTTP-RPC, or a binary TCP
15+
/// protocol, or any other RPC-type mechanism).
1116
/// </summary>
12-
/// <seealso cref="Microsoft.AspNetCore.NodeServices.INodeInstance" />
17+
/// <seealso cref="Microsoft.AspNetCore.NodeServices.HostingModels.INodeInstance" />
1318
public abstract class OutOfProcessNodeInstance : INodeInstance
1419
{
15-
private readonly object _childProcessLauncherLock;
16-
private string _commandLineArguments;
17-
private readonly StringAsTempFile _entryPointScript;
18-
private Process _nodeProcess;
19-
private TaskCompletionSource<bool> _nodeProcessIsReadySource;
20-
private readonly string _projectPath;
20+
private const string ConnectionEstablishedMessage = "[Microsoft.AspNetCore.NodeServices:Listening]";
21+
private readonly TaskCompletionSource<object> _connectionIsReadySource = new TaskCompletionSource<object>();
2122
private bool _disposed;
23+
private readonly StringAsTempFile _entryPointScript;
24+
private readonly Process _nodeProcess;
2225

2326
public OutOfProcessNodeInstance(string entryPointScript, string projectPath, string commandLineArguments = null)
2427
{
25-
_childProcessLauncherLock = new object();
2628
_entryPointScript = new StringAsTempFile(entryPointScript);
27-
_projectPath = projectPath;
28-
_commandLineArguments = commandLineArguments ?? string.Empty;
29+
_nodeProcess = LaunchNodeProcess(_entryPointScript.FileName, projectPath, commandLineArguments);
30+
ConnectToInputOutputStreams();
2931
}
3032

31-
public string CommandLineArguments
33+
public async Task<T> InvokeExportAsync<T>(string moduleName, string exportNameOrNull, params object[] args)
3234
{
33-
get { return _commandLineArguments; }
34-
set { _commandLineArguments = value; }
35-
}
35+
// Wait until the connection is established. This will throw if the connection fails to initialize.
36+
await _connectionIsReadySource.Task;
3637

37-
public Task<T> InvokeExportAsync<T>(string moduleName, string exportNameOrNull, params object[] args)
38-
{
39-
return InvokeExportAsync<T>(new NodeInvocationInfo
38+
if (_nodeProcess.HasExited)
39+
{
40+
// This special kind of exception triggers a transparent retry - NodeServicesImpl will launch
41+
// a new Node instance and pass the invocation to that one instead.
42+
throw new NodeInvocationException("The Node process has exited", null, nodeInstanceUnavailable: true);
43+
}
44+
45+
return await InvokeExportAsync<T>(new NodeInvocationInfo
4046
{
4147
ModuleName = moduleName,
4248
ExportedFunctionName = exportNameOrNull,
@@ -52,71 +58,74 @@ public void Dispose()
5258

5359
protected abstract Task<T> InvokeExportAsync<T>(NodeInvocationInfo invocationInfo);
5460

55-
protected void ExitNodeProcess()
61+
protected virtual void OnOutputDataReceived(string outputData)
62+
{
63+
Console.WriteLine("[Node] " + outputData);
64+
}
65+
66+
protected virtual void OnErrorDataReceived(string errorData)
67+
{
68+
Console.WriteLine("[Node] " + errorData);
69+
}
70+
71+
protected virtual void Dispose(bool disposing)
5672
{
57-
if (_nodeProcess != null && !_nodeProcess.HasExited)
73+
if (!_disposed)
5874
{
75+
if (disposing)
76+
{
77+
_entryPointScript.Dispose();
78+
}
79+
80+
// Make sure the Node process is finished
5981
// TODO: Is there a more graceful way to end it? Or does this still let it perform any cleanup?
60-
_nodeProcess.Kill();
82+
if (!_nodeProcess.HasExited)
83+
{
84+
_nodeProcess.Kill();
85+
}
86+
87+
_disposed = true;
6188
}
6289
}
6390

64-
protected async Task EnsureReady()
91+
private static Process LaunchNodeProcess(string entryPointFilename, string projectPath, string commandLineArguments)
6592
{
66-
lock (_childProcessLauncherLock)
93+
var startInfo = new ProcessStartInfo("node")
6794
{
68-
if (_nodeProcess == null || _nodeProcess.HasExited)
69-
{
70-
this.OnBeforeLaunchProcess();
95+
Arguments = "\"" + entryPointFilename + "\" " + (commandLineArguments ?? string.Empty),
96+
UseShellExecute = false,
97+
RedirectStandardInput = true,
98+
RedirectStandardOutput = true,
99+
RedirectStandardError = true,
100+
WorkingDirectory = projectPath
101+
};
71102

72-
var startInfo = new ProcessStartInfo("node")
73-
{
74-
Arguments = "\"" + _entryPointScript.FileName + "\" " + _commandLineArguments,
75-
UseShellExecute = false,
76-
RedirectStandardInput = true,
77-
RedirectStandardOutput = true,
78-
RedirectStandardError = true,
79-
WorkingDirectory = _projectPath
80-
};
81-
82-
// Append projectPath to NODE_PATH so it can locate node_modules
83-
var existingNodePath = Environment.GetEnvironmentVariable("NODE_PATH") ?? string.Empty;
84-
if (existingNodePath != string.Empty)
85-
{
86-
existingNodePath += ":";
87-
}
103+
// Append projectPath to NODE_PATH so it can locate node_modules
104+
var existingNodePath = Environment.GetEnvironmentVariable("NODE_PATH") ?? string.Empty;
105+
if (existingNodePath != string.Empty)
106+
{
107+
existingNodePath += ":";
108+
}
88109

89-
var nodePathValue = existingNodePath + Path.Combine(_projectPath, "node_modules");
110+
var nodePathValue = existingNodePath + Path.Combine(projectPath, "node_modules");
90111
#if NET451
91-
startInfo.EnvironmentVariables["NODE_PATH"] = nodePathValue;
112+
startInfo.EnvironmentVariables["NODE_PATH"] = nodePathValue;
92113
#else
93-
startInfo.Environment["NODE_PATH"] = nodePathValue;
114+
startInfo.Environment["NODE_PATH"] = nodePathValue;
94115
#endif
95116

96-
_nodeProcess = Process.Start(startInfo);
97-
ConnectToInputOutputStreams();
98-
}
99-
}
100-
101-
var task = _nodeProcessIsReadySource.Task;
102-
var initializationSucceeded = await task;
103-
104-
if (!initializationSucceeded)
105-
{
106-
throw new InvalidOperationException("The Node.js process failed to initialize", task.Exception);
107-
}
117+
return Process.Start(startInfo);
108118
}
109119

110120
private void ConnectToInputOutputStreams()
111121
{
112-
var initializationIsCompleted = false; // TODO: Make this thread-safe? (Interlocked.Exchange etc.)
113-
_nodeProcessIsReadySource = new TaskCompletionSource<bool>();
122+
var initializationIsCompleted = false;
114123

115124
_nodeProcess.OutputDataReceived += (sender, evt) =>
116125
{
117-
if (evt.Data == "[Microsoft.AspNetCore.NodeServices:Listening]" && !initializationIsCompleted)
126+
if (evt.Data == ConnectionEstablishedMessage && !initializationIsCompleted)
118127
{
119-
_nodeProcessIsReadySource.SetResult(true);
128+
_connectionIsReadySource.SetResult(null);
120129
initializationIsCompleted = true;
121130
}
122131
else if (evt.Data != null)
@@ -129,48 +138,23 @@ private void ConnectToInputOutputStreams()
129138
{
130139
if (evt.Data != null)
131140
{
132-
OnErrorDataReceived(evt.Data);
133141
if (!initializationIsCompleted)
134142
{
135-
_nodeProcessIsReadySource.SetResult(false);
143+
_connectionIsReadySource.SetException(
144+
new InvalidOperationException("The Node.js process failed to initialize: " + evt.Data));
136145
initializationIsCompleted = true;
137146
}
147+
else
148+
{
149+
OnErrorDataReceived(evt.Data);
150+
}
138151
}
139152
};
140153

141154
_nodeProcess.BeginOutputReadLine();
142155
_nodeProcess.BeginErrorReadLine();
143156
}
144157

145-
protected virtual void OnBeforeLaunchProcess()
146-
{
147-
}
148-
149-
protected virtual void OnOutputDataReceived(string outputData)
150-
{
151-
Console.WriteLine("[Node] " + outputData);
152-
}
153-
154-
protected virtual void OnErrorDataReceived(string errorData)
155-
{
156-
Console.WriteLine("[Node] " + errorData);
157-
}
158-
159-
protected virtual void Dispose(bool disposing)
160-
{
161-
if (!_disposed)
162-
{
163-
if (disposing)
164-
{
165-
_entryPointScript.Dispose();
166-
}
167-
168-
ExitNodeProcess();
169-
170-
_disposed = true;
171-
}
172-
}
173-
174158
~OutOfProcessNodeInstance()
175159
{
176160
Dispose(false);

0 commit comments

Comments
 (0)