Windows IPC Explained: Choosing the Right Transport for WinUI 3 Desktop Applications
A practical guide to App Services, Named Pipes, COM, Shared Memory, security boundaries, process activation, and why App Services in WinUI 3...
A practical guide to App Services, Named Pipes, COM, Shared Memory, security boundaries, process activation, and why App Services in WinUI 3 require a WinRT component.
Every interesting desktop feature I have shipped eventually stops being one process. A UI that must stay responsive, a worker that must outlive the window, a broker that must keep running while the user is logged in but never show a pixel. The moment you split them, you own an interface. And an interface between two Windows processes is not a function call. It is a security boundary, a lifetime contract, and a serialization format wearing a trench coat.
This post is the guide I wish I'd had: the real transports, what each one actually costs, and the one that everybody asks about first: App Services in WinUI 3, which the platform quietly refuses to host until you understand why.
Pick the transport by answering four questions
Engineers usually start with "named pipes or App Service?" That is the last question, not the first. Start here:
1. Who starts whom?
This is the question that eliminates the most options. If process A must be able to reach process B when B is not running, you need a transport with an activation model, meaning something that will launch B on demand. App Services and COM have that. Named pipes, sockets and shared memory do not: they assume the peer already exists, and if it doesn't you get FileNotFoundException and a decision to make about who launches what.
Getting this wrong is how you end up with the worst architecture in desktop software: a process that polls for a file, a registry key, or a well-known window handle to decide whether to start. I have deleted that code from more than one codebase.
2. Where is the trust boundary?
Both processes running as the same user in the same session is the easy case. It stops being easy the moment one of them is:
- elevated. A medium-IL process cannot simply talk to a high-IL one; you need an explicit ACL, and you need to think hard about whether you have just built a privilege-escalation vector
- a Windows service. Session 0 isolation means no window messages, no shared desktop, and kernel object names need the
Global\prefix - in an AppContainer. Sandboxed, and the default DACL on your pipe will not let it in
- a different user. Fast user switching exists and will find your bugs
3. Are you packaged?
MSIX identity is not a deployment detail; it is a capability. Package identity is what makes App Services possible at all, and it is what makes the peer's identity cryptographically checkable rather than a guess based on a file path.
4. What does the payload look like?
Small structured request/response is a different problem from a 60 fps video frame. Be honest about which one you have, because the answer changes the transport completely, and mixing them over one channel is how you get a control message stuck behind eight megabytes of pixels.
The landscape
| Transport | Activates peer? | Payload | Best at |
|---|---|---|---|
| App Service | Yes | ValueSet |
Packaged apps, request/response, brokered identity |
| Named pipe | No | Bytes | General purpose, cross-session, full control of ACL |
| Shared memory (MMF) | No | Raw bytes | Bulk / zero-copy: frames, buffers, big blobs |
| COM (out-of-proc) | Yes | Typed interfaces | Strong contracts, activation, existing COM estate |
| RPC / ALPC | Yes (via endpoint) | Typed (IDL) | What Windows itself uses; fast, fiddly |
| TCP loopback / gRPC | No | Bytes / protobuf | Cross-platform, cross-machine later |
WM_COPYDATA |
No | Blob | Legacy interop, single session only |
Two entries deserve a warning. WM_COPYDATA is synchronous, blocks the sender's message loop, and dies at the session 0 boundary. It is interop with the past, not a design. And "just use TCP on localhost" opens a port that every process on the box can connect to, including malware; if you go there, you own the authentication story entirely.
App Services: the brokered option
An App Service is a UI-less endpoint published by a packaged app and reachable by name. The broker is what makes it interesting: the caller does not open a handle to a path you chose, it asks the platform for a service by name and package family, and the platform does the introduction. Identity comes from the package, not from you.
The wire format is a ValueSet, a string-keyed bag of WinRT primitives. This is a genuine constraint, not a formality. You get primitives, arrays of primitives, and nested ValueSets. You do not get your types. In practice everyone lands on the same answer:
// The ValueSet is a transport, not a model. Serialise at the edge.
var request = new ValueSet
{
["op"] = nameof(DeviceOperation.Register),
["payload"] = JsonSerializer.Serialize(new RegisterRequest(deviceId, caps)),
["v"] = ProtocolVersion // version from day one; you will need it
};
AppServiceResponse response = await _connection.SendMessageAsync(request);
if (response.Status != AppServiceResponseStatus.Success)
return Result.Transport(response.Status);
// Two failure planes: transport (Status) and application (your own code).
// Conflating them is a bug you will chase for a week.
var body = (string)response.Message["payload"];
return JsonSerializer.Deserialize<RegisterResult>(body);
Declaring the service
<Package
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:uap4="http://schemas.microsoft.com/appx/manifest/uap/windows10/4">
<Applications>
<Application Id="App">
<Extensions>
<uap:Extension
Category="windows.appService"
EntryPoint="Contoso.Connectivity.Tasks.DeviceService">
<uap4:AppService Name="com.contoso.connectivity.devices" />
</uap:Extension>
</Extensions>
</Application>
</Applications>
</Package>
Two attributes worth knowing. The service Name is the contract, so reverse-DNS it, version it, and never rename it casually; callers hard-code it. And uap4:SupportsMultipleInstances tells the platform to spin up a fresh process per call, which is the difference between a stateless service and one where two callers stomp on each other's state.
The provider, and the deferral that everyone forgets
The single most common App Service bug is a service that answers the first message and then dies. That is not a bug in the platform; it is the platform doing exactly what it says. A background task is terminated when Run returns, and Run returns immediately, because subscribing to an event is not a blocking operation. The deferral is what keeps the process alive:
public sealed class DeviceService : IBackgroundTask
{
private BackgroundTaskDeferral _deferral;
private AppServiceConnection _connection;
public void Run(IBackgroundTaskInstance taskInstance)
{
// Without this line the process is gone before the first message lands.
_deferral = taskInstance.GetDeferral();
taskInstance.Canceled += OnCanceled;
var details = (AppServiceTriggerDetails)taskInstance.TriggerDetails;
_connection = details.AppServiceConnection;
_connection.RequestReceived += OnRequestReceived;
_connection.ServiceClosed += (s, e) => _deferral?.Complete();
}
private async void OnRequestReceived(
AppServiceConnection sender, AppServiceRequestReceivedEventArgs args)
{
// Take a deferral on the *request* too: the moment this handler
// returns, the platform considers the request answered.
var messageDeferral = args.GetDeferral();
try
{
var op = (string)args.Request.Message["op"];
var body = (string)args.Request.Message["payload"];
var result = await _dispatcher.HandleAsync(op, body);
await args.Request.SendResponseAsync(new ValueSet
{
["ok"] = true,
["payload"] = JsonSerializer.Serialize(result)
});
}
catch (Exception ex)
{
// Never let an exception escape into the platform's event handler.
await args.Request.SendResponseAsync(new ValueSet
{
["ok"] = false,
["error"] = ex.Message
});
}
finally
{
messageDeferral.Complete();
}
}
private void OnCanceled(IBackgroundTaskInstance sender, BackgroundTaskCancellationReason reason)
{
// You get milliseconds here, not seconds. Flush, don't compute.
_deferral?.Complete();
}
}
The lifetime rules are worth committing to memory, because they are unforgiving. Microsoft's guidance is explicit, and the numbers are small: a background-task-hosted service survives about 30 seconds past a call unless it is called again or holds a deferral. The ceiling then depends on the caller. A foreground caller lends the service its own lifetime, while a background caller gets that 30-second budget, with a deferral buying one extra five-second grace. The details are in Create and consume an app service.
Read that twice. Your service's lifetime is a function of your caller's state, not your own. Design your long operations to be resumable, or move them somewhere with a lifetime you control.
The WinUI 3 problem, and why it is not a bug
Here is where the story gets interesting, and where most guidance on the internet is either wrong or ten years stale.
App Service activation is delivered as a background activation. In UWP, that arrived at a specific place: Application.OnBackgroundActivated. You overrode it, pulled AppServiceTriggerDetails out of the args, and you were done.
WinUI 3 does not have that method. Not "it's broken". It does not exist. Microsoft's background task migration strategy states it plainly: UWP in-process tasks run their routines in the OnBackgroundActivated callback on the foreground process, and that approach simply isn't available in a WinUI app, because those callbacks don't exist there.
That single missing callback is the whole story. A WinUI 3 app is a Win32 desktop app wearing an MSIX package. It has package identity, so it can declare an App Service. But it has no UWP application object, so it has nowhere for the platform to deliver the activation. The extension is declared, the caller connects, and nothing happens.
The fix: give the platform a WinRT class it can activate
The platform doesn't want your Application object. It wants to activate a WinRT class by name. So give it one, and tell the manifest where the runtime that can host it lives.
Three pieces:
1. Put the task in a real WinRT component. Not a class in your app project, but a separate C# library that produces WinRT metadata. This is the supported shape, not a hack: the same migration guide notes that out-of-process tasks were always authored as WinRT components, and that when moving to the Windows App SDK you keep the component as-is and package it alongside the desktop project. In the .csproj:
<PropertyGroup> <TargetFramework>net8.0-windows10.0.19041.0</TargetFramework> <CsWinRTComponent>true</CsWinRTComponent> <!-- This is what emits WinRT.Host.dll + the runtimeconfig alongside your DLL --> <CsWinRTEnableDynamicObjectsSupport>true</CsWinRTEnableDynamicObjectsSupport> </PropertyGroup>
2. Register the activatable class, hosted by the WinRT host. This is the line that does the work, and the one almost nobody explains. For C# the docs call for an extra ActivatableClass registration in the manifest: a windows.activatableClass.inProcessServer extension wrapping an InProcessServer that carries a Path and an ActivatableClass marked ThreadingModel="both".
The <Path> is the crux. It does not point at your DLL. It points at WinRT.Host.dll, the CsWinRT host that loads the CLR, reads WinRT.Host.runtimeconfig.json, finds your managed class, and hands the platform a WinRT object. Shipping apps do exactly this. You can see the shape in a real manifest in WindowsAppSDK issue #4113, where each InProcessServer points its Path at the packaged WinRT.Host.dll and names the task class as the activatable class id.
<Package
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:uap4="http://schemas.microsoft.com/appx/manifest/uap/windows10/4">
<Applications>
<Application Id="App">
<Extensions>
<!-- 1. Publish the service under a stable, reverse-DNS name -->
<uap:Extension
Category="windows.appService"
EntryPoint="Contoso.Connectivity.Tasks.DeviceService">
<uap4:AppService Name="com.contoso.connectivity.devices" />
</uap:Extension>
<!-- 2. Tell Windows how to bring that class to life.
Path = the WinRT host, NOT your component. -->
<Extension Category="windows.activatableClass.inProcessServer">
<InProcessServer>
<Path>WinRT.Host.dll</Path>
<ActivatableClass
ActivatableClassId="Contoso.Connectivity.Tasks.DeviceService"
ThreadingModel="both" />
</InProcessServer>
</Extension>
</Extensions>
</Application>
</Applications>
</Package>
3. Make sure the host can find a runtime. WinRT.Host.runtimeconfig.json must sit beside WinRT.Host.dll in the package, and it must name a framework the machine actually has.
The EntryPoint and the ActivatableClassId must be byte-identical, fully qualified. A typo produces no error, just silence, which is the defining experience of this entire feature area.
Three traps I would not want you to learn the hard way
Self-contained deployment will betray you. There is a documented failure where a WinUI 3 app's background task runs on developer machines and dies on clean ones. The reason is structural: the background task host is a separate process, and it does not necessarily resolve the .NET runtime the way your app does. Your dev box has .NET installed. Your user's box does not. Test on a machine that has never seen Visual Studio. This class of bug is invisible until then.
The activation runs somewhere else. It is not your UI process. It has no window, no dispatcher, and no access to your singletons. If your task handler touches app state, you are already broken. You just haven't noticed.
The API surface has moved. If you are on a recent Windows App SDK, look at Microsoft.Windows.ApplicationModel.Background.BackgroundTaskBuilder before reaching for the WinRT one. The distinction is real: the Windows App SDK version exists precisely so desktop apps can register full-trust COM components directly against triggers, which removes the need to carry a WinRT component at all. If your scenario fits it, take it, because it is strictly less machinery than the above.
Getting the data from the App Service into your UI
This is the question that follows about ten minutes after you get the activation working, and it is the one that exposes whether you have understood the model. The message arrived. Your handler ran. Now how does it reach the window the user is looking at?
In UWP the answer was trivial and it spoiled a generation of us. OnBackgroundActivated ran inside the foreground process. The AppServiceConnection was an object in the same address space as your view models. You marshalled to the UI thread with the dispatcher and you were done. One process, one heap, no transport.
That is not what happens in WinUI 3, and it is the single most expensive misconception in this whole area.
Where your handler actually runs
When Windows activates Contoso.Connectivity.Tasks.DeviceService, it does not call into your running app. It follows the windows.activatableClass.inProcessServer registration, loads WinRT.Host.dll, spins up the CLR, and instantiates your class. That happens in a background task host process. Not your UI process. A different PID, a different heap, no XAML, no DispatcherQueue, and none of your singletons.
So the picture people carry in their heads is wrong:
What people assume: What actually happens:
[ Peer process ] [ Peer process ]
| |
| AppServiceConnection | AppServiceConnection
v v
[ Your WinUI app ] [ BackgroundTaskHost.exe ]
handler + UI WinRT.Host.dll
same process your DeviceService
|
? <-- there is a process
| boundary right here
v
[ Your WinUI app ]
That question mark is a second IPC hop, and nothing in the platform fills it in for you. You have solved inter-process communication and been handed a fresh inter-process communication problem as the prize. Once you see this clearly, the options sort themselves out.
Option 1: invert the direction (do this if you can)
Ninety percent of the time, the app that hosts the App Service should be the other process, and your WinUI app should be the client.
Consuming an App Service in WinUI 3 works perfectly. There is no missing callback, no manifest surgery, no host DLL. AppServiceConnection is just an object you create, and its RequestReceived event fires in your process, on your heap. The whole problem evaporates:
public sealed class UiSideConnection : IAsyncDisposable
{
private readonly DispatcherQueue _dispatcher;
private AppServiceConnection _connection;
public UiSideConnection(DispatcherQueue dispatcher) => _dispatcher = dispatcher;
public async Task<bool> ConnectAsync()
{
_connection = new AppServiceConnection
{
AppServiceName = "com.contoso.connectivity.devices",
PackageFamilyName = WorkerPackageFamilyName // the *provider's* PFN
};
// The provider is launched on demand if it isn't running.
var status = await _connection.OpenAsync();
if (status != AppServiceConnectionStatus.Success)
{
_log.Warning("App service open failed: {Status}", status);
return false;
}
// Fires on a thread pool thread IN THIS PROCESS. No second hop needed.
_connection.RequestReceived += OnPushFromWorker;
_connection.ServiceClosed += OnServiceClosed;
return true;
}
private void OnPushFromWorker(
AppServiceConnection sender, AppServiceRequestReceivedEventArgs args)
{
var deferral = args.GetDeferral();
try
{
var payload = (string)args.Request.Message["payload"];
var update = JsonSerializer.Deserialize<DeviceUpdate>(payload);
// Straight onto the UI thread. This is the whole point of the inversion.
_dispatcher.TryEnqueue(() => Devices.Apply(update));
}
finally
{
deferral.Complete();
}
}
}
The connection is bidirectional once open. The worker can push to you over the same channel with SendMessageAsync, and it lands in OnPushFromWorker above. If your worker is a packaged console app or a service you also ship, put the service there and stop fighting the platform.
Option 2: the task relays to the UI
Sometimes you cannot invert. A third-party app connects to your published service name, and that name has to live on your package. Now you genuinely must host, and you genuinely must bridge two processes.
The honest solution is a named pipe from the task host to the UI, which is why the next section of this post exists. The task becomes a thin relay:
// Inside the background task host process.
private async void OnRequestReceived(
AppServiceConnection sender, AppServiceRequestReceivedEventArgs args)
{
var deferral = args.GetDeferral();
try
{
var envelope = Envelope.FromValueSet(args.Request.Message);
// Is the UI actually up? Connecting to a pipe nobody is serving
// throws, and that is information, not an error.
if (await _uiBridge.IsUiRunningAsync())
{
var reply = await _uiBridge.SendAsync(envelope, _ct);
await args.Request.SendResponseAsync(reply.ToValueSet());
return;
}
// No UI. Handle it headlessly, or queue it, but do NOT
// launch the UI just to answer a message. Users notice.
var handled = await _headless.HandleAsync(envelope, _ct);
await args.Request.SendResponseAsync(handled.ToValueSet());
}
finally
{
deferral.Complete();
}
}
Note what that code refuses to do. It does not launch your UI to service a background message. A window appearing because some other app queried your service is a bug the user will report as "your app keeps opening by itself", and they will be right.
Option 3: a COM server inside the UI process
The most elegant option, and the most machinery. Register your UI executable as an out-of-process COM server via com:ExeServer in the manifest. The task calls CoCreateInstance; COM either routes to your already-running UI or launches it. You get a typed interface and activation, and you skip inventing a pipe protocol.
The cost is real: a CLSID, a threading model you have to get right, marshalling rules, and a launch-and-activation permission SDDL string that will teach you more about ACLs than you wanted to know. Take this route when you already have COM in the codebase, or when you need the type safety badly enough to pay for it.
Whichever you pick, marshal properly
Every one of these paths ends the same way: a message arrives on a non-UI thread and has to cross to the UI thread. In WinUI 3 that is DispatcherQueue, and there is a trap in it. DispatcherQueue.GetForCurrentThread() returns null on a background thread, which means the natural-looking code compiles and then throws in production at the worst moment. Capture the dispatcher once, while you are still on the UI thread:
// In your window or view model constructor, on the UI thread.
_dispatcher = DispatcherQueue.GetForCurrentThread();
// Later, on whatever thread the IPC callback arrives on:
if (!_dispatcher.TryEnqueue(() => Devices.Apply(update)))
{
// Returns false when the queue is shutting down. The window is closing;
// dropping the update is correct here, but log it once so you know.
_log.Debug("Dispatcher rejected update; UI is shutting down.");
}
And do not enqueue per message if messages arrive in bursts. Every TryEnqueue is a UI-thread work item, and a device that reports 200 updates a second will starve your rendering. Batch on the background thread, enqueue on a timer, and let the UI thread do one coalesced update.
Named pipes: the one you'll actually reach for
When I need two processes to talk and I do not need the platform to launch one of them, this is the answer. A named pipe is a kernel object with a name and, crucially, a security descriptor you control. That last part is the whole reason it beats a loopback socket.
Framing is your job
The most common named-pipe bug in .NET: assuming one Write equals one Read. It does not. In byte mode a pipe is a stream, and your 4 KB message will arrive as 1400 bytes and then 2696 bytes on exactly the machine you can't debug. Message mode helps, but a length prefix is explicit and portable:
private static async Task WriteFrameAsync(Stream s, byte[] payload, CancellationToken ct)
{
var header = BitConverter.GetBytes(payload.Length); // 4-byte length prefix
await s.WriteAsync(header, ct);
await s.WriteAsync(payload, ct);
await s.FlushAsync(ct);
}
private static async Task<byte[]> ReadFrameAsync(Stream s, CancellationToken ct)
{
var header = new byte[4];
await ReadExactlyAsync(s, header, ct);
var length = BitConverter.ToInt32(header);
// A hostile or broken peer will send you int.MaxValue. Don't allocate it.
if (length < 0 || length > MaxFrameBytes)
throw new InvalidDataException($"Frame length {length} out of range.");
var payload = new byte[length];
await ReadExactlyAsync(s, payload, ct);
return payload;
}
private static async Task ReadExactlyAsync(Stream s, byte[] buffer, CancellationToken ct)
{
var read = 0;
while (read < buffer.Length)
{
// 0 means the peer closed. Not "try again".
var n = await s.ReadAsync(buffer.AsMemory(read), ct);
if (n == 0) throw new EndOfStreamException("Peer closed mid-frame.");
read += n;
}
}
The ACL is the design
The default DACL on a named pipe is more generous than you think, and "nobody knows the name" is not access control, because pipe names are enumerable. Say what you mean:
var security = new PipeSecurity();
// Only the interactive user who owns this session.
security.AddAccessRule(new PipeAccessRule(
WindowsIdentity.GetCurrent().User,
PipeAccessRights.ReadWrite | PipeAccessRights.CreateNewInstance,
AccessControlType.Allow));
// Deny the network principal outright: a named pipe is reachable over SMB.
security.AddAccessRule(new PipeAccessRule(
new SecurityIdentifier(WellKnownSidType.NetworkSid, null),
PipeAccessRights.FullControl,
AccessControlType.Deny));
var server = NamedPipeServerStreamAcl.Create(
pipeName: PipeName,
direction: PipeDirection.InOut,
maxNumberOfServerInstances: 4, // concurrency ceiling; pick deliberately
transmissionMode: PipeTransmissionMode.Byte,
options: PipeOptions.Asynchronous | PipeOptions.WriteThrough,
inBufferSize: 64 * 1024,
outBufferSize: 64 * 1024,
pipeSecurity: security);
That Deny rule is not paranoia. \\.\pipe\ has a remote form, \\server\pipe\, and it travels over SMB. If you have never explicitly denied NetworkSid, your local IPC channel may be less local than you assume.
Accept in a loop, and mind the race
A single-instance server that reconnects after each client has a window where a connecting client gets ERROR_PIPE_BUSY. Pre-create instances and accept in a loop:
while (!ct.IsCancellationRequested)
{
var server = CreateServerInstance();
await server.WaitForConnectionAsync(ct);
// Do NOT await the session here: that serialises your clients.
_ = Task.Run(() => ServeAsync(server, ct), ct)
.ContinueWith(t => server.Dispose(), TaskScheduler.Default);
}
Verify who is on the other end
This is the part most tutorials omit, and it is the part that matters. A pipe tells you a client connected. It does not tell you the client deserves to be there. On Windows you can find out:
// Requires the pipe handle; P/Invoke GetNamedPipeClientProcessId.
if (!GetNamedPipeClientProcessId(server.SafePipeHandle.DangerousGetHandle(), out var pid))
throw new Win32Exception(Marshal.GetLastWin32Error());
var imagePath = QueryFullProcessImageName(pid);
// Path alone proves nothing: anyone can name their binary MyApp.exe.
// Verify the signature chain, and pin the publisher.
if (!AuthenticodeVerifier.IsSignedBy(imagePath, ExpectedPublisher))
{
_log.Warning("Rejected pipe client {Pid} at {Path}: publisher mismatch.", pid, imagePath);
server.Disconnect();
return;
}
If both ends are MSIX-packaged, there is a stronger check available: resolve the client's package family name from its process token and compare that. Package identity is signed and enforced by the platform; a file path is a string.
One more, and it is a real one: NamedPipeServerStream.RunAsClient lets your server impersonate the caller. It is occasionally the right tool and frequently a confused-deputy vulnerability. If your privileged service impersonates whichever unprivileged process connected, think very carefully about what you have just built.
Shared memory, for when the payload is the point
Pipes and App Services both copy. For a control channel that is irrelevant. For 1080p frames at 60 fps it is the entire problem, because you will burn your budget in memcpy and GC pressure before you touch the wire.
The pattern that works: shared memory for the payload, a pipe for the control channel. A memory-mapped file carries the bytes, a named event signals readiness, and the pipe carries "frame 41 is ready in slot 2".
// Producer
using var mmf = MemoryMappedFile.CreateOrOpen("Local\\Contoso.Frames", capacity);
using var accessor = mmf.CreateViewAccessor(slotOffset, slotSize);
using var ready = new EventWaitHandle(false, EventResetMode.AutoReset, "Local\\Contoso.FrameReady");
accessor.WriteArray(0, frameBytes, 0, frameBytes.Length);
ready.Set(); // the pipe then carries the tiny "slot 2, seq 41" message
Note the Local\ prefix, which scopes the object to the session. Use Global\ only when you genuinely need to cross into session 0, and understand that you have just widened your attack surface to every session on the machine.
Shared memory gives you no framing, no ordering and no safety. You are hand-rolling a ring buffer with sequence numbers, and there is no reader/writer lock unless you build one. Reach for it when you have measured a copy problem, not before.
gRPC over loopback
Every so often the right answer is to stop using a Windows-specific transport. If your two processes are not both .NET, or you can see a future where the peer moves to another machine or a container, gRPC earns its place: a real IDL, generated clients on both ends, streaming in both directions, and versioning rules that were designed rather than discovered.
The mechanism is worth being precise about, because "gRPC" bundles three separate things: HTTP/2 as the transport, protobuf as the encoding, and a code generator that turns a .proto file into typed stubs. On loopback you keep all three, and the framing problem you hand-rolled for named pipes is simply gone. HTTP/2 frames the messages, multiplexes concurrent calls over one connection, and handles flow control. That is not nothing: multiplexing is exactly the thing you would otherwise build badly at 2am when a bulk transfer blocks a heartbeat.
syntax = "proto3";
package contoso.connectivity;
service DeviceService {
rpc Register (RegisterRequest) returns (RegisterReply);
rpc WatchDevices (WatchRequest) returns (stream DeviceEvent); // server push
}
message RegisterRequest { string device_id = 1; repeated string caps = 2; }
message RegisterReply { bool ok = 1; string reason = 2; }
message DeviceEvent { string device_id = 1; DeviceState state = 2; }
That stream keyword is why people reach for this. Server push over a named pipe means designing a notification protocol yourself. Here it is one word, and the generated client hands you an async stream.
The catch nobody mentions until you ship
A TCP port is not an access control mechanism. This is the whole trade. Your named pipe had a DACL: the kernel enforced who could open it, before a single byte of your code ran. A loopback socket has nothing. Any process running as any user on that machine can connect to 127.0.0.1:5001, including malware running as a different, lower-privileged account. You have moved authentication from the kernel into your own code, and you now have to write it, correctly, forever.
Consequences you own the moment you bind a port:
- Port selection. A hard-coded port collides with something eventually, and users cannot fix it. Bind to port 0, let the OS assign, then publish the real port somewhere only your peer can read.
- Authentication. At minimum, a per-session token generated at startup and passed in call metadata. Then verify the caller's PID and signature the way you would on a pipe.
- Firewall prompts. Bind explicitly to
IPAddress.Loopback, neverIPAddress.Any. Get this wrong and your users get a Windows Firewall dialog, or worse, they do not and you are listening on the LAN. - TLS. gRPC generally wants HTTPS. On loopback that means a dev certificate story, which is its own weekend.
// Bind loopback only, on an OS-assigned port. Never IPAddress.Any.
builder.WebHost.ConfigureKestrel(options =>
{
options.Listen(IPAddress.Loopback, port: 0, listenOptions =>
{
listenOptions.Protocols = HttpProtocols.Http2; // h2c: no TLS on loopback
});
});
var app = builder.Build();
app.MapGrpcService<DeviceServiceImpl>();
await app.StartAsync();
// The OS picked the port. Tell the peer through a channel it already trusts:
// a file in LocalAppData with a restrictive ACL, or a registry key under HKCU.
var port = new Uri(app.Urls.First()).Port;
await _portFile.PublishAsync(port, _sessionToken);
And the interceptor that does the job the kernel used to do for you:
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
TRequest request, ServerCallContext context,
UnaryServerMethod<TRequest, TResponse> continuation)
{
var token = context.RequestHeaders.GetValue("x-session-token");
// Fixed-time compare. A string equality check here leaks the token
// one byte at a time to anything that can measure your latency.
if (token is null || !CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(token), _expectedToken))
{
throw new RpcException(new Status(StatusCode.Unauthenticated, "Bad token."));
}
return await continuation(request, context);
}
On the wire, expect gRPC on loopback to cost more per call than a named pipe: HTTP/2 framing, header handling and protobuf are not free, and for a chatty control channel between two .NET processes on one box that overhead buys you nothing a pipe was not already giving you. Measure your own numbers rather than trusting mine or anyone else's.
So the rule I use: gRPC when the contract or the future is doing the work. Polyglot peers, a schema that many teams must agree on, streaming you would otherwise hand-roll, or a peer that might move off-box. If it is two .NET processes on one desktop and a stable contract, a named pipe is less machinery and strictly more secure by default.
So: which one?
- Both ends packaged, request/response, peer may not be running → App Service. The broker handles activation and identity.
- You need a long-lived, high-frequency channel, or one end isn't packaged → named pipe, with a deliberate ACL and peer verification.
- Crossing into session 0 (a service) → named pipe with
Global\naming. App Services and window messages are not options. - Bulk data → shared memory for the payload, pipe for control.
- Strongly-typed contract with activation, and you already have COM → out-of-proc COM server.
- Polyglot peers, a shared schema, or a peer that may move off-box → gRPC over loopback, bound to
IPAddress.Loopbackon an OS-assigned port, with a session token and peer verification you write yourself.
The sample project
Everything above is in a working repository: github.com/yeasir007/WinIpcLab. Clone it, run the worker, run the app, and put a breakpoint wherever you stopped believing me.
WinIpcLab/
src/
WinIpcLab.Contracts/ net8.0, deliberately NOT windows-targeted
Envelope.cs the unit that crosses the boundary
Ops.cs operation names as strings, not enum values
Messages.cs request/reply records
WinIpcLab.Transport/ net8.0-windows
IMessageChannel.cs the seam. the UI sees only this
ChannelFactory.cs picks by capability, not by #if
NamedPipe/
PipeFraming.cs length prefix + ReadExactly
PipeSecurityFactory.cs the ACL is the design
PeerVerifier.cs who is actually on the other end
NamedPipeServer.cs accept loop, N instances
NamedPipeChannel.cs client, multiplexed by correlation id
AppService/
AppServiceChannel.cs the UI as CLIENT. this direction just works
WinIpcLab.Tasks/ CsWinRTComponent=true
DeviceAppService.cs IBackgroundTask + both deferrals
UiBridge.cs the second hop, when you must host
Package.appxmanifest.snippet.xml
WinIpcLab.Worker/ the peer. owns the registry, serves the pipe
WinIpcLab.App/ WinUI 3. MainViewModel.cs is the interesting file
tests/
WinIpcLab.Tests/ framing tests. the ones that matter
docs/appservice.md why WinUI 3 cannot host, and the fix
The dependency rule
One arrow decides whether this design survives:
Contracts <---- Transport <---- Worker
^ ^
| |
+----- App -----+ App never names a transport type.
WinIpcLab.Contracts targets plain net8.0, not net8.0-windows, and that is not an oversight. It is a tripwire. The day somebody reaches for a ValueSet or a PipeStream in the contract, the compiler stops them. An abstraction that depends on your discipline will leak; one the compiler enforces will not.
The seam
public interface IMessageChannel : IAsyncDisposable
{
bool IsConnected { get; }
event EventHandler<Envelope>? MessageReceived; // server push
event EventHandler<string>? Disconnected;
Task<bool> ConnectAsync(CancellationToken ct = default);
/// Throws TransportException for transport failure.
/// Returns an Envelope with Ok=false for application failure.
/// Those are different things and must never be collapsed.
Task<Envelope> SendAsync(Envelope request, CancellationToken ct = default);
}
That doc comment is the most important part of the interface. "The pipe broke" and "the device id was invalid" are not the same event, do not have the same retry policy, and must not arrive through the same channel. Every IPC codebase I have seen that conflates them has a retry loop that hammers a peer which is answering perfectly well and saying no.
The contract
public sealed record Envelope
{
[JsonPropertyName("v")]
public int Version { get; init; } = ProtocolVersion.Current; // from commit one
[JsonPropertyName("op")]
public required string Op { get; init; }
// Ties a reply to its request. Mandatory the moment a transport
// multiplexes; free insurance when it doesn't.
[JsonPropertyName("cid")]
public string CorrelationId { get; init; } = Guid.NewGuid().ToString("N");
[JsonPropertyName("payload")]
public string Payload { get; init; } = "{}";
[JsonPropertyName("ok")]
public bool Ok { get; init; } = true;
[JsonPropertyName("error")]
public string? Error { get; init; }
}
Version it from the first commit. Adding a version field later is itself a breaking change, and you will be adding it during an incident.
The tests that earn their place
There are only two test files, and one of them is doing all the work. Framing is where pipe code fails, and it fails under load on a machine you cannot attach a debugger to:
[Fact]
public async Task ReadAsync_reassembles_a_frame_split_across_many_reads()
{
var sent = Envelope.Request(Ops.Register, new RegisterRequest("dev-1", new[] { "a" }));
using var buffer = new MemoryStream();
await PipeFraming.WriteAsync(buffer, sent, default);
// Feed it back ONE BYTE PER READ: pathological, legal, and what a real
// pipe will eventually do to you at the worst possible moment.
using var dribble = new DribbleStream(buffer.ToArray(), bytesPerRead: 1);
var received = await PipeFraming.ReadAsync(dribble, default);
Assert.Equal("dev-1", received.Body<RegisterRequest>()!.DeviceId);
}
[Fact]
public async Task ReadAsync_rejects_an_absurd_length_without_allocating()
{
// A hostile peer sends int.MaxValue as the length prefix. The naive
// reader calls new byte[2147483647] and the process is gone.
var evil = new byte[4];
BitConverter.TryWriteBytes(evil, int.MaxValue);
using var stream = new MemoryStream(evil);
await Assert.ThrowsAsync<TransportException>(() => PipeFraming.ReadAsync(stream, default));
}
If you take one file from the repo, take PipeFraming.cs and its tests. Everything else is architecture you can argue with. That one is arithmetic you cannot.
What the repo is honest about
Three gaps are marked in the code rather than papered over. Authenticode.IsSignedBy reads the embedded certificate but does not call WinVerifyTrust, so it proves a signature exists, not that the chain is trusted: ship the real check. The gRPC channel is described here but not implemented; IMessageChannel is where it goes. And PeerVerifier is wired but passed null in the worker so the sample runs unsigned, which is exactly the shortcut you must not carry into production.
Closing
IPC on Windows looks like a menu of transports. It isn't. It is a set of questions about lifetime, trust and payload, and the transport falls out of the answers. Get the questions right and the code is boring, which, for something two processes depend on, is the highest compliment available.
The App Service situation in WinUI 3 is the sharpest example. The feature isn't missing and it isn't broken. One callback that used to exist does not exist any more, and everything downstream follows from that. Once you see it, the fix is obvious: stop expecting the platform to call your application object, and hand it a WinRT class it can activate instead.
