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:
yyc12345 committed 2026-10-02 14:29:42 +08:00
1 parent b5614ff59b
commit 4fc8fdda6f
9 files changed
+485 -3

No files matched your search

+87
View File
@@ -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!));
}
}
+47
View File
@@ -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
View File
@@ -4,6 +4,8 @@ using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Data.Core; using Avalonia.Data.Core;
using Avalonia.Data.Core.Plugins; using Avalonia.Data.Core.Plugins;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using YKanBan.Ipc;
using YKanBan.ViewModels; using YKanBan.ViewModels;
using YKanBan.Views; using YKanBan.Views;
@@ -25,12 +27,33 @@ public partial class App : Application
{ {
DataContext = new MainWindowViewModel(), DataContext = new MainWindowViewModel(),
}; };
// Primary instance: serve launch commands forwarded by secondary instances.
Program.SingleInstance?.StartServer(command => HandleLaunchCommand(desktop, command));
} }
base.OnFrameworkInitializationCompleted(); 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 = var dataValidationPluginsToRemove =
BindingPlugins.DataValidators.OfType<DataAnnotationsValidationPlugin>().ToArray(); BindingPlugins.DataValidators.OfType<DataAnnotationsValidationPlugin>().ToArray();
+25
View File
@@ -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);
}
+62
View File
@@ -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]}'");
}
}
}
+18
View File
@@ -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);
}
}
+15
View File
@@ -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;
}
+188
View File
@@ -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
View File
@@ -1,13 +1,30 @@
using System; using System;
using Avalonia; using Avalonia;
using YKanBan.Ipc;
namespace YKanBan; namespace YKanBan;
sealed class Program 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] [STAThread]
public static void Main(string[] args) => BuildAvaloniaApp() public static void Main(string[] args)
.StartWithClassicDesktopLifetime(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() public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>() => AppBuilder.Configure<App>()