feat(m4): single instance IPC
- LaunchCommand: ShowMain / OpenWorkspace(path), doubles as IPC message - LaunchArguments: first non-blank arg wins, blank-only args -> ShowMain - IpcProtocol: strict newline-line codec (show / open+path), tolerant of trailing-newline framing only - SingleInstanceService: per-session named mutex decides primary; primary serves a named-pipe loop (decode -> dispatch -> ack) with handler failures contained; secondary retries send, exits on ack - Program/App wiring: secondary forwards command and exits; primary activates the main window for forwarded commands (workspace switch deferred to the workspace UI milestone) - 15 new tests (protocol codec, argument parsing); smoke-verified: instance 1 stays alive, instance 2 self-exits with code 0
This commit is contained in:
1 parent
b5614ff59b
commit
4fc8fdda6f
9 files changed
+485
-3
No files matched your search
@@ -0,0 +1,87 @@
|
||||
using YKanBan.Ipc;
|
||||
|
||||
namespace YKanBan.Tests.Ipc;
|
||||
|
||||
/// <summary>Tests for the IPC wire protocol codec.</summary>
|
||||
[TestClass]
|
||||
public class IpcProtocolTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void ShowMainEncodesAsSingleLine()
|
||||
{
|
||||
Assert.AreEqual("show\n", IpcProtocol.Encode(new LaunchCommand.ShowMain()));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void OpenWorkspaceEncodesCommandAndPathLines()
|
||||
{
|
||||
Assert.AreEqual(
|
||||
"open\nD:\\my repo\n",
|
||||
IpcProtocol.Encode(new LaunchCommand.OpenWorkspace(@"D:\my repo")));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void RoundTripPreservesCommands()
|
||||
{
|
||||
LaunchCommand[] commands =
|
||||
[
|
||||
new LaunchCommand.ShowMain(),
|
||||
new LaunchCommand.OpenWorkspace(@"D:\my repo"),
|
||||
new LaunchCommand.OpenWorkspace("/home/user/仓库"),
|
||||
];
|
||||
|
||||
foreach (LaunchCommand command in commands)
|
||||
{
|
||||
Assert.AreEqual(command, IpcProtocol.Decode(IpcProtocol.Encode(command)));
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DecodeToleratesMissingTrailingNewline()
|
||||
{
|
||||
Assert.AreEqual(new LaunchCommand.ShowMain(), IpcProtocol.Decode("show"));
|
||||
Assert.AreEqual(
|
||||
new LaunchCommand.OpenWorkspace(@"C:\x"),
|
||||
IpcProtocol.Decode("open\nC:\\x"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DecodePreservesPathsWithSpacesAndUnicode()
|
||||
{
|
||||
LaunchCommand command = IpcProtocol.Decode("open\nD:\\我的 仓库\n");
|
||||
Assert.AreEqual(new LaunchCommand.OpenWorkspace(@"D:\我的 仓库"), command);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DecodeRejectsEmptyPayload()
|
||||
{
|
||||
Assert.ThrowsExactly<FormatException>(() => IpcProtocol.Decode(string.Empty));
|
||||
Assert.ThrowsExactly<FormatException>(() => IpcProtocol.Decode("\n"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DecodeRejectsUnknownCommand()
|
||||
{
|
||||
Assert.ThrowsExactly<FormatException>(() => IpcProtocol.Decode("frobnicate\n"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DecodeRejectsOpenWithoutPath()
|
||||
{
|
||||
Assert.ThrowsExactly<FormatException>(() => IpcProtocol.Decode("open\n"));
|
||||
Assert.ThrowsExactly<FormatException>(() => IpcProtocol.Decode("open"));
|
||||
Assert.ThrowsExactly<FormatException>(() => IpcProtocol.Decode("open\n\n"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DecodeRejectsContentAfterShow()
|
||||
{
|
||||
Assert.ThrowsExactly<FormatException>(() => IpcProtocol.Decode("show\nextra\n"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void EncodeRejectsNullCommand()
|
||||
{
|
||||
Assert.ThrowsExactly<InvalidOperationException>(() => IpcProtocol.Encode(null!));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using YKanBan.Ipc;
|
||||
|
||||
namespace YKanBan.Tests.Ipc;
|
||||
|
||||
/// <summary>Tests for command-line argument parsing.</summary>
|
||||
[TestClass]
|
||||
public class LaunchArgumentsTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void NoArgumentsYieldShowMain()
|
||||
{
|
||||
Assert.AreEqual(new LaunchCommand.ShowMain(), LaunchArguments.Parse([]));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void BlankArgumentsOnlyYieldShowMain()
|
||||
{
|
||||
Assert.AreEqual(new LaunchCommand.ShowMain(), LaunchArguments.Parse(["", " "]));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SingleArgumentBecomesWorkspacePath()
|
||||
{
|
||||
Assert.AreEqual(
|
||||
new LaunchCommand.OpenWorkspace(@"D:\repo"),
|
||||
LaunchArguments.Parse([@"D:\repo"]));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void PathWithSpacesIsPreserved()
|
||||
{
|
||||
Assert.AreEqual(
|
||||
new LaunchCommand.OpenWorkspace(@"D:\my repo\ws"),
|
||||
LaunchArguments.Parse([@"D:\my repo\ws"]));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void FirstMeaningfulArgumentWins()
|
||||
{
|
||||
Assert.AreEqual(
|
||||
new LaunchCommand.OpenWorkspace(@"D:\a"),
|
||||
LaunchArguments.Parse([@"D:\a", @"D:\b"]));
|
||||
Assert.AreEqual(
|
||||
new LaunchCommand.OpenWorkspace(@"D:\b"),
|
||||
LaunchArguments.Parse(["", @"D:\b", @"D:\c"]));
|
||||
}
|
||||
}
|
||||
+24
-1
@@ -4,6 +4,8 @@ using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Data.Core;
|
||||
using Avalonia.Data.Core.Plugins;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Threading;
|
||||
using YKanBan.Ipc;
|
||||
using YKanBan.ViewModels;
|
||||
using YKanBan.Views;
|
||||
|
||||
@@ -25,12 +27,33 @@ public partial class App : Application
|
||||
{
|
||||
DataContext = new MainWindowViewModel(),
|
||||
};
|
||||
|
||||
// Primary instance: serve launch commands forwarded by secondary instances.
|
||||
Program.SingleInstance?.StartServer(command => HandleLaunchCommand(desktop, command));
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
|
||||
private void DisableAvaloniaDataAnnotationValidation()
|
||||
/// <summary>
|
||||
/// Brings the main window to front for a forwarded launch command. Both
|
||||
/// ShowMain and OpenWorkspace activate the window; switching to the
|
||||
/// requested workspace lands together with the workspace UI.
|
||||
/// </summary>
|
||||
private static void HandleLaunchCommand(IClassicDesktopStyleApplicationLifetime desktop, LaunchCommand command)
|
||||
{
|
||||
// IPC delivery happens on a background thread; window access must hop to the UI thread.
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (desktop.MainWindow is { } window)
|
||||
{
|
||||
window.Show();
|
||||
window.Activate();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static void DisableAvaloniaDataAnnotationValidation()
|
||||
{
|
||||
var dataValidationPluginsToRemove =
|
||||
BindingPlugins.DataValidators.OfType<DataAnnotationsValidationPlugin>().ToArray();
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace YKanBan.Ipc;
|
||||
|
||||
/// <summary>
|
||||
/// Single-instance detection and inter-instance messaging, built on a named
|
||||
/// mutex (detection) and a named pipe (command forwarding). The primary
|
||||
/// instance holds the mutex for its lifetime and serves the pipe; secondary
|
||||
/// instances forward their launch command to the primary and exit.
|
||||
/// </summary>
|
||||
public interface ISingleInstanceService : IDisposable
|
||||
{
|
||||
/// <summary>Attempts to register this process as the single YKanBan instance.</summary>
|
||||
/// <returns>True when this process is the primary instance; false when another one exists.</returns>
|
||||
bool TryAcquireSingleInstance();
|
||||
|
||||
/// <summary>Starts serving launch commands on a background task (primary instance only).</summary>
|
||||
/// <param name="handler">Invoked for each decoded command; runs on a background thread.</param>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// Not called on a primary instance, or called a second time.
|
||||
/// </exception>
|
||||
void StartServer(Action<LaunchCommand> handler);
|
||||
|
||||
/// <summary>Delivers a command to the primary instance and waits for its acknowledgement.</summary>
|
||||
/// <returns>True when the primary instance acknowledged the command.</returns>
|
||||
bool TrySend(LaunchCommand command);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
namespace YKanBan.Ipc;
|
||||
|
||||
/// <summary>
|
||||
/// Text encoding of launch commands on the IPC pipe. A message is one or
|
||||
/// two newline-terminated lines: "show", or "open" followed by the
|
||||
/// workspace path taken verbatim (paths may contain spaces; paths
|
||||
/// containing newlines are not supported). Decoding is strict and throws
|
||||
/// FormatException on malformed payloads.
|
||||
/// </summary>
|
||||
public static class IpcProtocol
|
||||
{
|
||||
/// <summary>Encodes a command into its wire text.</summary>
|
||||
/// <exception cref="InvalidOperationException">The command type is unknown.</exception>
|
||||
public static string Encode(LaunchCommand command) => command switch
|
||||
{
|
||||
LaunchCommand.ShowMain => "show\n",
|
||||
LaunchCommand.OpenWorkspace open => $"open\n{open.Path}\n",
|
||||
_ => throw new InvalidOperationException($"Unsupported launch command: {command?.GetType().Name ?? "null"}"),
|
||||
};
|
||||
|
||||
/// <summary>Decodes wire text into a command; trailing newlines are tolerated.</summary>
|
||||
/// <exception cref="FormatException">The payload is not a valid message.</exception>
|
||||
public static LaunchCommand Decode(string payload)
|
||||
{
|
||||
List<string> lines = payload.Split('\n').ToList();
|
||||
|
||||
// Trailing newlines are a framing detail; drop the empty lines they produce.
|
||||
while (lines.Count > 0 && lines[^1].Length == 0)
|
||||
{
|
||||
lines.RemoveAt(lines.Count - 1);
|
||||
}
|
||||
|
||||
if (lines.Count == 0)
|
||||
{
|
||||
throw new FormatException("Empty IPC payload.");
|
||||
}
|
||||
|
||||
switch (lines[0])
|
||||
{
|
||||
case "show":
|
||||
if (lines.Count != 1)
|
||||
{
|
||||
throw new FormatException($"Unexpected content after 'show': '{lines[1]}'");
|
||||
}
|
||||
return new LaunchCommand.ShowMain();
|
||||
|
||||
case "open":
|
||||
if (lines.Count != 2)
|
||||
{
|
||||
throw new FormatException("'open' requires exactly one path line.");
|
||||
}
|
||||
if (lines[1].Length == 0)
|
||||
{
|
||||
throw new FormatException("'open' requires a non-empty path.");
|
||||
}
|
||||
return new LaunchCommand.OpenWorkspace(lines[1]);
|
||||
|
||||
default:
|
||||
throw new FormatException($"Unknown IPC command: '{lines[0]}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace YKanBan.Ipc;
|
||||
|
||||
/// <summary>Parses command-line arguments into a launch command.</summary>
|
||||
public static class LaunchArguments
|
||||
{
|
||||
/// <summary>
|
||||
/// Parses raw command-line arguments: with no meaningful argument the
|
||||
/// command is ShowMain; otherwise the first non-blank argument is taken
|
||||
/// as the workspace path and any further arguments are ignored.
|
||||
/// </summary>
|
||||
public static LaunchCommand Parse(IReadOnlyList<string> arguments)
|
||||
{
|
||||
string? path = arguments.FirstOrDefault(argument => !string.IsNullOrWhiteSpace(argument));
|
||||
return path is null
|
||||
? new LaunchCommand.ShowMain()
|
||||
: new LaunchCommand.OpenWorkspace(path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace YKanBan.Ipc;
|
||||
|
||||
/// <summary>
|
||||
/// A launch command: what a YKanBan invocation asks the application to do.
|
||||
/// Doubles as the IPC message forwarded from a secondary instance to the
|
||||
/// primary one.
|
||||
/// </summary>
|
||||
public abstract record LaunchCommand
|
||||
{
|
||||
/// <summary>No arguments: show the main window and bring it to front.</summary>
|
||||
public sealed record ShowMain : LaunchCommand;
|
||||
|
||||
/// <summary>A workspace path: activate the window and switch to (or open) the workspace.</summary>
|
||||
public sealed record OpenWorkspace(string Path) : LaunchCommand;
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
using System.IO.Pipes;
|
||||
using System.Text;
|
||||
|
||||
namespace YKanBan.Ipc;
|
||||
|
||||
/// <summary>
|
||||
/// Mutex + named pipe implementation of <see cref="ISingleInstanceService"/>.
|
||||
/// The mutex name is per-user-session (Local\ prefix), so separate users on
|
||||
/// one machine run independently. A crash releases the mutex with the
|
||||
/// process — no stale state survives.
|
||||
/// </summary>
|
||||
public sealed class SingleInstanceService : ISingleInstanceService
|
||||
{
|
||||
private const string MutexName = @"Local\YKanBan.SingleInstance";
|
||||
private const string PipeName = "YKanBan.Ipc";
|
||||
private const int ConnectTimeoutMilliseconds = 2000;
|
||||
private const int ConnectAttempts = 3;
|
||||
private const int RetryDelayMilliseconds = 300;
|
||||
|
||||
private readonly object _gate = new();
|
||||
private Mutex? _mutex;
|
||||
private CancellationTokenSource? _serverCancellation;
|
||||
private bool _isPrimary;
|
||||
|
||||
/// <summary>Attempts to register this process as the single YKanBan instance.</summary>
|
||||
/// <exception cref="InvalidOperationException">Acquired a second time.</exception>
|
||||
public bool TryAcquireSingleInstance()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_mutex is not null)
|
||||
{
|
||||
throw new InvalidOperationException("Single instance already acquired.");
|
||||
}
|
||||
|
||||
// Ownership is requested up front; createdNew tells primary from secondary.
|
||||
_mutex = new Mutex(initiallyOwned: true, MutexName, out bool createdNew);
|
||||
_isPrimary = createdNew;
|
||||
return createdNew;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Starts serving launch commands on a background task (primary instance only).</summary>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// Not called on a primary instance, or called a second time.
|
||||
/// </exception>
|
||||
public void StartServer(Action<LaunchCommand> handler)
|
||||
{
|
||||
CancellationTokenSource cancellation;
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_isPrimary || _mutex is null)
|
||||
{
|
||||
throw new InvalidOperationException("Only the primary instance can serve IPC.");
|
||||
}
|
||||
if (_serverCancellation is not null)
|
||||
{
|
||||
throw new InvalidOperationException("IPC server already started.");
|
||||
}
|
||||
_serverCancellation = new CancellationTokenSource();
|
||||
cancellation = _serverCancellation;
|
||||
}
|
||||
|
||||
_ = Task.Run(() => ServerLoopAsync(handler, cancellation.Token));
|
||||
}
|
||||
|
||||
/// <summary>Delivers a command to the primary instance and waits for its acknowledgement.</summary>
|
||||
public bool TrySend(LaunchCommand command)
|
||||
{
|
||||
string payload = IpcProtocol.Encode(command);
|
||||
|
||||
// The primary may still be starting its server; retry briefly before giving up.
|
||||
for (int attempt = 0; attempt < ConnectAttempts; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = new NamedPipeClientStream(".", PipeName, PipeDirection.InOut);
|
||||
client.Connect(ConnectTimeoutMilliseconds);
|
||||
|
||||
using (var writer = new StreamWriter(client, Encoding.UTF8, bufferSize: 64, leaveOpen: true)
|
||||
{
|
||||
AutoFlush = true,
|
||||
})
|
||||
{
|
||||
writer.Write(payload);
|
||||
}
|
||||
|
||||
using var reader = new StreamReader(client, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 64, leaveOpen: true);
|
||||
string acknowledgement = reader.ReadLine() ?? string.Empty;
|
||||
return acknowledgement == "ok";
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
Thread.Sleep(RetryDelayMilliseconds);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
Thread.Sleep(RetryDelayMilliseconds);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Releases the mutex and stops the IPC server.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_serverCancellation?.Cancel();
|
||||
_serverCancellation?.Dispose();
|
||||
_serverCancellation = null;
|
||||
_mutex?.Dispose();
|
||||
_mutex = null;
|
||||
_isPrimary = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Accepts one client at a time: read, decode, dispatch, acknowledge, disconnect.</summary>
|
||||
private static async Task ServerLoopAsync(Action<LaunchCommand> handler, CancellationToken cancellation)
|
||||
{
|
||||
while (!cancellation.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var server = new NamedPipeServerStream(PipeName, PipeDirection.InOut, maxNumberOfServerInstances: 1);
|
||||
await server.WaitForConnectionAsync(cancellation);
|
||||
|
||||
string payload = await ReadMessageAsync(server, cancellation);
|
||||
LaunchCommand command = IpcProtocol.Decode(payload);
|
||||
|
||||
// Dispatch to the app; handler failures must not kill the server.
|
||||
try
|
||||
{
|
||||
handler(command);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
// Swallow handler exceptions: one bad dispatch must not stop serving.
|
||||
}
|
||||
|
||||
WriteAcknowledgement(server);
|
||||
server.Disconnect();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Client vanished mid-conversation: drop the connection and keep serving.
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
// Malformed payload: no acknowledgement is written; keep serving.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reads the command line plus, for open, the path line.</summary>
|
||||
private static async Task<string> ReadMessageAsync(PipeStream pipe, CancellationToken cancellation)
|
||||
{
|
||||
using var reader = new StreamReader(pipe, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true);
|
||||
|
||||
string firstLine = await reader.ReadLineAsync(cancellation)
|
||||
?? throw new IOException("Connection closed before the command line arrived.");
|
||||
if (firstLine != "open")
|
||||
{
|
||||
return firstLine;
|
||||
}
|
||||
|
||||
string? pathLine = await reader.ReadLineAsync(cancellation);
|
||||
if (pathLine is null)
|
||||
{
|
||||
throw new IOException("Connection closed before the path line arrived.");
|
||||
}
|
||||
return $"open\n{pathLine}";
|
||||
}
|
||||
|
||||
/// <summary>Writes the acknowledgement the sender waits for.</summary>
|
||||
private static void WriteAcknowledgement(PipeStream pipe)
|
||||
{
|
||||
using var writer = new StreamWriter(pipe, Encoding.UTF8, bufferSize: 64, leaveOpen: true)
|
||||
{
|
||||
AutoFlush = true,
|
||||
};
|
||||
writer.Write("ok\n");
|
||||
}
|
||||
}
|
||||
+19
-2
@@ -1,13 +1,30 @@
|
||||
using System;
|
||||
using Avalonia;
|
||||
using YKanBan.Ipc;
|
||||
|
||||
namespace YKanBan;
|
||||
|
||||
sealed class Program
|
||||
{
|
||||
/// <summary>The single-instance service held by this process while the app runs.</summary>
|
||||
internal static ISingleInstanceService? SingleInstance { get; private set; }
|
||||
|
||||
[STAThread]
|
||||
public static void Main(string[] args) => BuildAvaloniaApp()
|
||||
.StartWithClassicDesktopLifetime(args);
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
LaunchCommand command = LaunchArguments.Parse(args);
|
||||
using ISingleInstanceService singleInstance = new SingleInstanceService();
|
||||
|
||||
// Secondary instance: forward the command to the primary, then exit immediately.
|
||||
if (!singleInstance.TryAcquireSingleInstance())
|
||||
{
|
||||
singleInstance.TrySend(command);
|
||||
return;
|
||||
}
|
||||
|
||||
SingleInstance = singleInstance;
|
||||
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||
}
|
||||
|
||||
public static AppBuilder BuildAvaloniaApp()
|
||||
=> AppBuilder.Configure<App>()
|
||||
|
||||
Reference in new issue
Block a user