In simple scenarios of communication between two processes on the same machine, one process can start another and pass information via environment variables or command-line arguments. It can also receive the execution result through return codes or standard output.
However, in more complex situations, a communication channel must be established and maintained between the two processes for as long as necessary. In these cases, a more advanced inter-process communication (IPC) mechanism with a well-defined contract is appropriate. This allows both processes to communicate asynchronously and bidirectionally using structured messages.
In this article, we’ll explore two different ways to implement such a communication channel using Remote Procedure Call (RPC). The calling process (client) and the called process (server) will both be implemented using C# and .NET, but clients could be implemented in other languages as well.
RPC with ASP.NET Core over named pipes
The first approach involves using ASP.NET Core on the server side. Typically, the transport layer would be HTTP (TCP/IP), but this would be overkill for inter-process communication on the same machine. Additionally, there might be security concerns and limitations related to using network ports.
Instead, we’ll configure ASP.NET Core to use local named pipes. A named pipe is an IPC mechanism that allows a process to read from or write to a shared channel. On Unix-based platforms, named pipes are represented by Unix sockets.
var builder = WebApplication.CreateBuilder();
builder.WebHost.ConfigureKestrel(options =>
{
options.ListenNamedPipe("myapp"); // Use a named pipe for communication
});
var app = builder.Build();
app.MapGet("/hello", () => "Hello world!");
app.Run();
Bootstrapping a full ASP.NET Core server with its default configuration can be a bit heavy. You can use a lightweight, Native AOT-friendly version by registering only the services you absolutely need. This version includes just the essentials to expose an endpoint over a named pipe.
var builder = WebApplication.CreateEmptyBuilder(new WebApplicationOptions
{
Args = args,
EnvironmentName = Environments.Production
});
builder.WebHost.UseKestrelCore();
builder.WebHost.ConfigureKestrel(options =>
{
options.ListenNamedPipe("myapp");
});
builder.Services.AddRoutingCore();
var app = builder.Build();
app.MapGet("/hello", () => "Hello world!");
app.Run();
On the client side, we configure the primary handler of HttpClient
, SocketsHttpHandler
, to connect to the local named pipe:
using System.IO.Pipes;
await using var pipeClientStream = new NamedPipeClientStream(
serverName: ".",
pipeName: "myapp",
PipeDirection.InOut,
PipeOptions.Asynchronous);
var primaryHandler = new SocketsHttpHandler
{
ConnectCallback = async (_, cancellationToken) =>
{
await pipeClientStream.ConnectAsync(cancellationToken);
return pipeClientStream;
}
};
var httpClient = new HttpClient(primaryHandler)
{
BaseAddress = new Uri("http://localhost")
};
var result = await httpClient.GetStringAsync("/hello");
Console.WriteLine(result); // Hello world!
You can use clients with other runtimes besides .NET as long as they support named pipes. With this technique, it’s simply JSON over named pipes. .NET developers familiar with ASP.NET Core will feel at ease. If you’re uncomfortable embedding a Kestrel server in your application, remember that the Docker daemon uses named pipes or Unix sockets for communication and exposes its operations through a RESTful API documented using OpenAPI, and it works just fine.
JSON-RPC with StreamJsonRpc and named pipes
The second approach is lighter and faster than using ASP.NET Core. It’s actually so efficient that it’s used by the C# Dev Kit for Visual Studio Code, enabling the JavaScript extension to interact with the Roslyn Language Server Protocol (LSP) implemented in C#. Since the client (VS Code) invokes features like autocompletion and syntax validation while developers write code, communication must be extremely performant.
Let’s start by defining the contract between the client and the server for demonstration purposes:
public interface IGreeter : IDisposable
{
Task<string> SayHelloAsync(string to);
}
Then, both the client and the server must install the StreamJsonRpc NuGet package, developed by Microsoft.
The server must implement the IGreeter
interface:
public sealed class Greeter : IGreeter
{
public Task<string> SayHelloAsync(string to)
{
return Task.FromResult($"Hello, {to}!");
}
public void Dispose()
{
// StreamJsonRpc recommends implementing Dispose to encourage developers
// to dispose of the client RPC proxies generated from the interface.
// // https://github.com/microsoft/vs-streamjsonrpc/blob/v2.19.27/doc/dynamicproxy.md#dispose-patterns
}
}
Here’s a simplified version of the server’s main program (without error handling, cancellation support or anything that would make it production-ready):
using System.IO.Pipes;
using StreamJsonRpc;
await using var pipeServerStream = new NamedPipeServerStream(
pipeName: "myapp",
PipeDirection.InOut,
NamedPipeServerStream.MaxAllowedServerInstances,
PipeTransmissionMode.Byte,
PipeOptions.Asynchronous);
await pipeServerStream.WaitForConnectionAsync();
var rpc = JsonRpc.Attach(pipeServerStream, new Greeter());
await rpc.Completion;
On the client side, StreamJsonRpc dynamically generates a proxy for the IGreeter
interface, using JSON-RPC and the named pipe under the hood:
using System.IO.Pipes;
using StreamJsonRpc;
await using var pipeClientStream = new NamedPipeClientStream(
serverName: ".",
pipeName: "myapp",
PipeDirection.InOut,
PipeOptions.Asynchronous);
await pipeClientStream.ConnectAsync();
using var greeter = JsonRpc.Attach<IGreeter>(pipeClientStream);
var result = await greeter.SayHelloAsync("Alice");
Console.WriteLine(result); // Hello, Alice!
While StreamJsonRpc supports using standard input/output instead of named pipes, I wouldn’t recommend it, as it can be challenging to ensure nothing else interferes with the formatting of RPC messages on stdout.
We’ve only scratched the surface of what StreamJsonRpc can do. The documentation notes that better performance can be achieved by formatting messages with MessagePack instead of JSON.
Conclusion
Introducing new executables can help decouple a complex application, enabling parallel development with many teams, reducing conflicts, allowing access to new and other technologies, and improving scalability overall. In such cases, it’s crucial to define clear communication contracts between processes.
In this article, we explored two developer-friendly and cost-effective ways to implement an IPC channel using ASP.NET Core and StreamJsonRpc for performant, efficient, and structured communication.
References
- Using named pipes with ASP.NET Core and HttpClient by Andrew Lock
- StreamJsonRpc documentation on GitHub
- How to: Use Named Pipes for Network Interprocess Communication on Microsoft Learn
- Usages of StreamJsonRpc in various Microsoft tools and LSP servers on GitHub