Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 11 additions & 76 deletions Source/Hurl.BrowserSelector/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,6 @@
using Microsoft.Extensions.Hosting;
using System;
using System.Diagnostics;
using System.IO;
using System.IO.Pipes;
using System.Security.AccessControl;
using System.Security.Principal;
using System.Text.Json;
using System.Threading;
using System.Windows;
Expand All @@ -26,13 +22,10 @@ public partial class App : Application
private MainWindow? _mainWindow;

private const string MUTEX_NAME = "Hurl_Mutex_3721";
private const string EVENT_NAME = "Hurl_Event_3721";

private Mutex? _singleInstanceMutex;
private EventWaitHandle? _singleInstanceWaitHandle;
private NamedPipeUrlReceiver? _pipeReceiver;

private readonly CancellationTokenSource _cancelTokenSource = new();
private Thread? _pipeServerListenThread;
public static IHost? AppHost { get; private set; }

public App()
Expand Down Expand Up @@ -77,41 +70,19 @@ private void Dispatcher_UnhandledException(object sender, System.Windows.Threadi
protected override void OnStartup(StartupEventArgs e)
{
_singleInstanceMutex = new Mutex(true, MUTEX_NAME, out var isOwned);
_singleInstanceWaitHandle = new EventWaitHandle(false, EventResetMode.AutoReset, EVENT_NAME);

if (!isOwned)
{
_singleInstanceWaitHandle.Set();
// Another instance is running - it will receive the URL via pipe from the Launcher
Shutdown();
return;
}

new Thread(() =>
{
while (_singleInstanceWaitHandle.WaitOne())
{
Current.Dispatcher.BeginInvoke(() =>
{
if (Current.MainWindow is { } window)
{
_mainWindow?.ShowWindow();
}
else
{
Shutdown();
}
});
}
})
{
IsBackground = true
}.Start();

_pipeServerListenThread = new Thread(PipeServer);
_pipeServerListenThread.Start();
// Start the pipe receiver (always ready for connections with overlapping listeners)
_pipeReceiver = new NamedPipeUrlReceiver(OnInstanceInvoked);
_pipeReceiver.Start();

var cliArgs = CliArgs.GatherInfo(e.Args, false);
//OpenedUri.Value = cliArgs.Url;
AppHost?.Services.GetRequiredService<CurrentUrlService>().Set(cliArgs.Url);

_mainWindow = new();
Expand All @@ -120,16 +91,17 @@ protected override void OnStartup(StartupEventArgs e)

protected override void OnExit(ExitEventArgs e)
{
_cancelTokenSource.Cancel();
_pipeServerListenThread?.Join();
if (_pipeReceiver != null)
{
_pipeReceiver.DisposeAsync().AsTask().GetAwaiter().GetResult();
}

_singleInstanceMutex?.Close();
_singleInstanceWaitHandle?.Close();

base.OnExit(e);
}

public void OnInstanceInvoked(string[] args)
private void OnInstanceInvoked(string[] args)
{
Current.Dispatcher.InvokeAsync(() =>
{
Expand All @@ -139,47 +111,10 @@ public void OnInstanceInvoked(string[] args)
if (!IsTimedSet)
{
Debug.WriteLine($"Hurl Browser Selector: Instance Invoked with URL: {cliArgs.Url}");
AppHost.Services.GetRequiredService<CurrentUrlService>().Set(cliArgs.Url);
AppHost?.Services.GetRequiredService<CurrentUrlService>().Set(cliArgs.Url);
_mainWindow?.Init(cliArgs);
}
});
}

public void PipeServer()
{
PipeSecurity pipeSecurity = new();
pipeSecurity.AddAccessRule(new PipeAccessRule(
new SecurityIdentifier(WellKnownSidType.WorldSid, null),
PipeAccessRights.ReadWrite,
AccessControlType.Allow));

while (!_cancelTokenSource.Token.IsCancellationRequested)
{
try
{
using var _pipeserver = NamedPipeServerStreamAcl.Create(
"HurlNamedPipe",
PipeDirection.InOut, 1,
PipeTransmissionMode.Byte,
PipeOptions.Asynchronous,
0, 0,
pipeSecurity);
_pipeserver.WaitForConnectionAsync(_cancelTokenSource.Token).Wait();

using StreamReader sr = new(_pipeserver);
string args = sr.ReadToEnd();
string[] argsArray = JsonSerializer.Deserialize<string[]>(args) ?? [];
OnInstanceInvoked(argsArray);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception e)
{
Debug.WriteLine($"Error in PipeServer: {e.Message}");
}
}
}
}
}
168 changes: 168 additions & 0 deletions Source/Hurl.BrowserSelector/Services/NamedPipeUrlReceiver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
using System;
using System.Diagnostics;
using System.IO;
using System.IO.Pipes;
using System.Security.AccessControl;
using System.Security.Principal;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;

namespace Hurl.BrowserSelector.Services;

/// <summary>
/// A robust Named Pipe server that maintains overlapping server instances
/// to eliminate race conditions. Always has at least one pipe ready to accept connections.
/// </summary>
internal sealed class NamedPipeUrlReceiver : IAsyncDisposable
{
private const string PipeName = "HurlNamedPipe";
private const int MaxServerInstances = 4;
private const int TargetListenerCount = 2;

private readonly CancellationTokenSource _cts = new();
private readonly Action<string[]> _onUrlReceived;
private readonly PipeSecurity _pipeSecurity;

private int _activeListeners = 0;

public NamedPipeUrlReceiver(Action<string[]> onUrlReceived)
{
_onUrlReceived = onUrlReceived ?? throw new ArgumentNullException(nameof(onUrlReceived));

_pipeSecurity = new PipeSecurity();
_pipeSecurity.AddAccessRule(new PipeAccessRule(
new SecurityIdentifier(WellKnownSidType.WorldSid, null),
PipeAccessRights.ReadWrite,
AccessControlType.Allow));
}

public void Start()
{
for (int i = 0; i < TargetListenerCount; i++)
{
_ = StartListenerAsync();
}
}

private async Task StartListenerAsync()
{
Interlocked.Increment(ref _activeListeners);

try
{
while (!_cts.Token.IsCancellationRequested)
{
NamedPipeServerStream? pipeServer = null;

try
{
pipeServer = NamedPipeServerStreamAcl.Create(
PipeName,
PipeDirection.InOut,
MaxServerInstances,
PipeTransmissionMode.Byte,
PipeOptions.Asynchronous | PipeOptions.WriteThrough,
inBufferSize: 4096,
outBufferSize: 4096,
_pipeSecurity);

try
{
await pipeServer.WaitForConnectionAsync(_cts.Token).ConfigureAwait(false);
}
catch (IOException ex) when (ex.HResult == -2147024664) // ERROR_PIPE_CONNECTED
{
// Client connected before WaitForConnectionAsync was called - connection is already established
}

// Spawn replacement listener IMMEDIATELY before processing
EnsureMinimumListeners();

await ProcessConnectionAsync(pipeServer).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
Debug.WriteLine($"Pipe listener error: {ex.Message}");
try
{
await Task.Delay(100, _cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
}
finally
{
pipeServer?.Dispose();
}
}
}
finally
{
Interlocked.Decrement(ref _activeListeners);
}
}

private void EnsureMinimumListeners()
{
int current = Volatile.Read(ref _activeListeners);
// Spawn a replacement if we're at or below target, ensuring always have listeners ready
if (current <= TargetListenerCount && !_cts.Token.IsCancellationRequested)
{
_ = StartListenerAsync();
}
}

private async Task ProcessConnectionAsync(NamedPipeServerStream pipeServer)
{
try
{
using var reader = new StreamReader(pipeServer, leaveOpen: true);

using var readCts = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token);
readCts.CancelAfter(TimeSpan.FromSeconds(5));

string data = await reader.ReadToEndAsync(readCts.Token).ConfigureAwait(false);

if (!string.IsNullOrWhiteSpace(data))
{
string[]? args = JsonSerializer.Deserialize<string[]>(data);
if (args != null && args.Length > 0)
{
_onUrlReceived(args);
}
}
}
catch (OperationCanceledException)
{
// Timeout or shutdown
}
catch (JsonException ex)
{
Debug.WriteLine($"Failed to parse pipe data: {ex.Message}");
}
catch (Exception ex)
{
Debug.WriteLine($"Error processing pipe connection: {ex.Message}");
}
}

public async ValueTask DisposeAsync()
{
_cts.Cancel();

int maxWaitIterations = 50;
while (Volatile.Read(ref _activeListeners) > 0 && maxWaitIterations-- > 0)
{
await Task.Delay(100).ConfigureAwait(false);
}

_cts.Dispose();
}
}