Making Single-Instance Activation Reliable in a WPF App
After removing the administrator requirement, I needed project-open and protocol activation flows to reach the running instance reliably. A mutex plus named pipe was not enough on its own.
- WPF
- .NET
- Named Pipes
- Desktop
- Single Instance
Why this problem showed up
This issue became important while removing the administrator requirement from the application and hardening normal-user startup behavior. Once the app no longer depended on elevated launches, the single-instance handoff path had to behave reliably for file-open requests, protocol callbacks, and plain window activation.
The existing structure already had the usual pieces: a mutex to detect the first instance and a named pipe to forward arguments from later launches. In practice, that worked most of the time, but not always. In some launches the pipe path could time out or fail with an access-denied style error, so the second process could not reliably wake the first instance or pass the incoming request.
Keep the fast path, but stop depending on only one path
I did not want to throw away the named pipe solution because it is still the fastest and cleanest path when it works. The real change was to stop treating pipe success as the only way an activation request could be delivered.
csharp
SingleInstanceRelay.cs
The pipe remains the first choice, but timeout or connection failure now falls back to a queued activation request.
public static bool ForwardToRunningInstance(string[] args)
{
try
{
using var pipeClient = new NamedPipeClientStream(".", "App.SingleInstance", PipeDirection.Out);
pipeClient.Connect(3000);
using var writer = new StreamWriter(pipeClient);
string payload = args.Length > 0 ? string.Join("|", args) : "ACTIVATE";
writer.WriteLine(payload);
writer.Flush();
return true;
}
catch (TimeoutException)
{
return EnqueueActivationRequest(args);
}
catch
{
return EnqueueActivationRequest(args);
}
}The fallback is just a small request queue
The fallback does not need to be complex. If pipe delivery fails, the second instance writes a tiny activation request file under the application's AppData folder. The running instance watches that folder and processes queued requests on the UI side.
That gave me a second delivery channel for project files, protocol URLs, or a plain window activation request. The important part is not the file itself. The important part is that the request is no longer lost just because one IPC attempt failed.
csharp
ActivationRequestQueue.cs
Writing to a temp file and then renaming it helps the watcher see only complete requests.
private static bool EnqueueActivationRequest(string[] args)
{
Directory.CreateDirectory(_requestDirectory);
string requestId = $"{DateTime.UtcNow:yyyyMMddHHmmssfffffff}_{Environment.ProcessId}_{Guid.NewGuid():N}";
string tempFile = Path.Combine(_requestDirectory, $"{requestId}.tmp");
string requestFile = Path.Combine(_requestDirectory, $"{requestId}.request");
string payload = args.Length > 0 ? string.Join("|", args) : "ACTIVATE";
File.WriteAllText(tempFile, payload, Encoding.UTF8);
File.Move(tempFile, requestFile);
return true;
}One activation handler for every message type
I also wanted the running instance to treat pipe messages and queued requests exactly the same way. So both paths now end in one activation handler.
- ACTIVATE only brings the main window to the front.
- If the message is a project file path, open it in the running instance.
- If the message is a protocol callback, complete the authentication flow.
- If the message contains multiple arguments, parse and handle them through the same rules.
csharp
ActivationMessageRouter.cs
Unifying the dispatch logic avoids maintaining different behaviors for pipe and fallback deliveries.
private static void RouteActivationMessage(string payload)
{
if (string.IsNullOrWhiteSpace(payload))
return;
if (LooksLikeProjectFile(payload))
{
OpenProjectInRunningInstance(payload);
return;
}
if (payload.StartsWith("app://", StringComparison.OrdinalIgnoreCase))
{
CompleteProtocolCallback(payload);
return;
}
if (payload == "ACTIVATE")
{
BringMainWindowToFront();
return;
}
if (payload.Contains("|"))
{
RouteCommandLineArguments(payload.Split('|'));
}
}Why this version was better
The real improvement was resilience. After removing the admin requirement, I no longer wanted activation to depend on one pipe connection succeeding at exactly the right moment.
The final structure stayed small: mutex for ownership, named pipe for the fast path, and a lightweight queued fallback for the cases where the fast path does not behave perfectly. That made the desktop startup flow much more dependable without turning the feature into a large subsystem.
Share