From da3a82402ab7e175932eb86997c6212d5895bb1a Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Thu, 23 Jul 2026 09:04:39 -0400 Subject: [PATCH 1/3] Fix duplicate instances on toast click when IPC forward times out --- .../SingleInstanceRedirector.cs | 94 ++++++++++++------- 1 file changed, 62 insertions(+), 32 deletions(-) diff --git a/src/UniGetUI.Avalonia/Infrastructure/SingleInstanceRedirector.cs b/src/UniGetUI.Avalonia/Infrastructure/SingleInstanceRedirector.cs index a7f753be5c..c3ea4a75de 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/SingleInstanceRedirector.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/SingleInstanceRedirector.cs @@ -34,6 +34,11 @@ private static string BuildPipeName() private static Thread? _listener; + // The mutex proves a first instance exists, so keep probing this long before giving up. + private static readonly TimeSpan ForwardBudget = TimeSpan.FromSeconds(5); + // Bound each read so a client that connects but never sends EOF can't wedge the loop. + private static readonly TimeSpan ReadTimeout = TimeSpan.FromSeconds(5); + /// /// Start a background listener thread. When a second instance forwards its args, /// is invoked on the Avalonia UI thread. @@ -46,20 +51,18 @@ public static void StartListener(Action onArgsReceived) { try { - using var pipe = new NamedPipeServerStream( + var pipe = new NamedPipeServerStream( PipeName, PipeDirection.In, - maxNumberOfServerInstances: 1, - transmissionMode: PipeTransmissionMode.Byte); + maxNumberOfServerInstances: NamedPipeServerStream.MaxAllowedServerInstances, + transmissionMode: PipeTransmissionMode.Byte, + options: PipeOptions.Asynchronous); pipe.WaitForConnection(); - using var reader = new StreamReader(pipe, Encoding.UTF8); - string payload = reader.ReadToEnd(); - - // Args are newline-delimited. - var args = payload.Split('\n', StringSplitOptions.RemoveEmptyEntries); - Dispatcher.UIThread.Post(() => onArgsReceived(args)); + // Read off-thread and immediately re-arm: a single slow/stuck client can + // never block the next accept nor "busy out" the pipe into timeouts. + HandleConnection(pipe, onArgsReceived); } catch (Exception ex) when (ex is not ThreadAbortException) { @@ -76,36 +79,63 @@ public static void StartListener(Action onArgsReceived) _listener.Start(); } + private static void HandleConnection(NamedPipeServerStream pipe, Action onArgsReceived) + { + _ = Task.Run(async () => + { + try + { + using (pipe) + using (var reader = new StreamReader(pipe, Encoding.UTF8)) + using (var cts = new CancellationTokenSource(ReadTimeout)) + { + string payload = await reader.ReadToEndAsync(cts.Token); + // Args are newline-delimited. + var args = payload.Split('\n', StringSplitOptions.RemoveEmptyEntries); + Dispatcher.UIThread.Post(() => onArgsReceived(args)); + } + } + catch (Exception ex) + { + Logger.Warn($"SingleInstanceRedirector read error: {ex.Message}"); + } + }); + } + /// /// Try to forward to the already-running first instance. /// /// true if the message was delivered successfully. public static bool TryForwardToFirstInstance(string[] args) { - if (args.Length == 0) - { - // Nothing to forward — still show the window by sending an empty payload. - } + var deadline = DateTime.UtcNow + ForwardBudget; + Exception? last = null; - try - { - using var pipe = new NamedPipeClientStream( - serverName: ".", - pipeName: PipeName, - direction: PipeDirection.Out); - - pipe.Connect(500); - - using var writer = new StreamWriter(pipe, Encoding.UTF8); - // Newline-delimited args payload. - writer.Write(string.Join('\n', args)); - writer.Flush(); - return true; - } - catch (Exception ex) + do { - Logger.Warn($"Could not forward args to first instance: {ex.Message}"); - return false; - } + try + { + using var pipe = new NamedPipeClientStream( + serverName: ".", + pipeName: PipeName, + direction: PipeDirection.Out); + + pipe.Connect(500); + + using var writer = new StreamWriter(pipe, Encoding.UTF8); + // Newline-delimited args payload. + writer.Write(string.Join('\n', args)); + writer.Flush(); + return true; + } + catch (Exception ex) + { + last = ex; + Thread.Sleep(100); + } + } while (DateTime.UtcNow < deadline); + + Logger.Warn($"Could not forward args to first instance: {last?.Message}"); + return false; } } From 7fe403ad9f897f634a945bc918869388d10278d2 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Thu, 23 Jul 2026 09:26:32 -0400 Subject: [PATCH 2/3] Satisfy code style check (drop redundant initializer) --- .../Infrastructure/SingleInstanceRedirector.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/UniGetUI.Avalonia/Infrastructure/SingleInstanceRedirector.cs b/src/UniGetUI.Avalonia/Infrastructure/SingleInstanceRedirector.cs index c3ea4a75de..0b10c643ca 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/SingleInstanceRedirector.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/SingleInstanceRedirector.cs @@ -109,8 +109,7 @@ private static void HandleConnection(NamedPipeServerStream pipe, Action Date: Thu, 23 Jul 2026 11:04:57 -0400 Subject: [PATCH 3/3] Address Copilot review: fix pipe handle leak and bound the client write --- .../SingleInstanceRedirector.cs | 42 +++++++++++++------ 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/src/UniGetUI.Avalonia/Infrastructure/SingleInstanceRedirector.cs b/src/UniGetUI.Avalonia/Infrastructure/SingleInstanceRedirector.cs index 0b10c643ca..fd79927b0f 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/SingleInstanceRedirector.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/SingleInstanceRedirector.cs @@ -49,9 +49,10 @@ public static void StartListener(Action onArgsReceived) { while (true) { + NamedPipeServerStream? pipe = null; try { - var pipe = new NamedPipeServerStream( + pipe = new NamedPipeServerStream( PipeName, PipeDirection.In, maxNumberOfServerInstances: NamedPipeServerStream.MaxAllowedServerInstances, @@ -63,12 +64,18 @@ public static void StartListener(Action onArgsReceived) // Read off-thread and immediately re-arm: a single slow/stuck client can // never block the next accept nor "busy out" the pipe into timeouts. HandleConnection(pipe, onArgsReceived); + pipe = null; // ownership handed to the read task } catch (Exception ex) when (ex is not ThreadAbortException) { // Keep the listener alive through errors (the pipe breaks on disconnect, etc.) Logger.Warn($"SingleInstanceRedirector listener error: {ex.Message}"); } + finally + { + // Dispose the stream if the handoff never happened, so a broken accept can't leak handles. + pipe?.Dispose(); + } } }) { @@ -107,32 +114,41 @@ private static void HandleConnection(NamedPipeServerStream pipe, Action /// true if the message was delivered successfully. public static bool TryForwardToFirstInstance(string[] args) + => ForwardToFirstInstanceAsync(args).GetAwaiter().GetResult(); + + private static async Task ForwardToFirstInstanceAsync(string[] args) { - var deadline = DateTime.UtcNow + ForwardBudget; - Exception? last; - do + // One token bounds connect, write and flush across retries, so a peer that connects + // then stops reading can never block past the budget and skip the fallback. + using var cts = new CancellationTokenSource(ForwardBudget); + byte[] payload = Encoding.UTF8.GetBytes(string.Join('\n', args)); // newline-delimited args + Exception? last = null; + + while (true) { try { using var pipe = new NamedPipeClientStream( serverName: ".", pipeName: PipeName, - direction: PipeDirection.Out); - - pipe.Connect(500); + direction: PipeDirection.Out, + options: PipeOptions.Asynchronous); - using var writer = new StreamWriter(pipe, Encoding.UTF8); - // Newline-delimited args payload. - writer.Write(string.Join('\n', args)); - writer.Flush(); + await pipe.ConnectAsync(cts.Token).ConfigureAwait(false); + await pipe.WriteAsync(payload, cts.Token).ConfigureAwait(false); + await pipe.FlushAsync(cts.Token).ConfigureAwait(false); return true; } catch (Exception ex) { last = ex; - Thread.Sleep(100); + if (cts.IsCancellationRequested) + break; + + try { await Task.Delay(100, cts.Token).ConfigureAwait(false); } + catch (OperationCanceledException) { break; } } - } while (DateTime.UtcNow < deadline); + } Logger.Warn($"Could not forward args to first instance: {last?.Message}"); return false;