Files
LanMountainDesktop/LanMountainDesktop.Shared.IPC/PublicIpcHostService.cs
lincube d8f75e86be Add IPC backoff/retries and safer disposal
Introduce exponential backoff, jitter and retry logic across IPC components to improve robustness and avoid tight retry loops; make disposal idempotent and add connection guards. Key changes:
- LauncherCoordinatorIpcServer / LauncherIpcServer: add backoff constants, ComputeBackoff(), consecutive error tracking and delayed retries with jitter.
- LanMountainDesktopIpcClient / LauncherIpcClient: add connect retry loops, timeouts, delayed retries, improved error logging, and use ArrayPool for buffered async writes; ensure proper cleanup on failures.
- PublicIpcHostService: add disposed flag, guard OnPeerConnected and Dispose, and clear connected peers on dispose.
- Add many auto-generated commit analysis docs under docs/auto_commit_md and new scripts for analyzing/generating commit docs.
These changes aim to make IPC connection handling more resilient and resource-safe.
2026-05-07 21:39:21 +08:00

235 lines
7.9 KiB
C#

using System.Reflection;
using System.Collections.Concurrent;
using dotnetCampus.Ipc.Context;
using dotnetCampus.Ipc.CompilerServices.GeneratedProxies;
using dotnetCampus.Ipc.IpcRouteds.DirectRouteds;
using dotnetCampus.Ipc.Pipes;
namespace LanMountainDesktop.Shared.IPC;
public sealed class PublicIpcHostService : IDisposable, IExternalIpcNotificationPublisher
{
private static readonly MethodInfo CreateIpcJointMethod = typeof(GeneratedIpcFactory)
.GetMethods(BindingFlags.Public | BindingFlags.Static)
.Single(method =>
method.Name == nameof(GeneratedIpcFactory.CreateIpcJoint) &&
method.IsGenericMethodDefinition &&
method.GetParameters().Length == 3);
private readonly Dictionary<(Type ContractType, string ObjectId), PublicServiceEntry> _services = new();
private readonly ConcurrentDictionary<string, PeerProxy> _connectedPeers = new(StringComparer.OrdinalIgnoreCase);
private readonly object _gate = new();
private bool _started;
private bool _disposed;
public PublicIpcHostService(string pipeName = IpcConstants.DefaultPipeName)
{
PipeName = pipeName;
StartedAt = DateTimeOffset.UtcNow;
Provider = new IpcProvider(pipeName);
RoutedProvider = new JsonIpcDirectRoutedProvider(Provider);
}
public string PipeName { get; }
public DateTimeOffset StartedAt { get; }
public IpcProvider Provider { get; }
public JsonIpcDirectRoutedProvider RoutedProvider { get; }
public Func<IReadOnlyList<PublicPluginDescriptor>> PluginDescriptorProvider { get; set; } =
static () => Array.Empty<PublicPluginDescriptor>();
public void Start()
{
if (_started)
{
return;
}
RoutedProvider.AddRequestHandler(IpcConstants.Routes.SessionGetInfo, () => BuildSessionInfo());
RoutedProvider.AddRequestHandler(IpcConstants.Routes.CatalogGet, () => GetCatalogSnapshot());
Provider.PeerConnected += OnPeerConnected;
RoutedProvider.StartServer();
_started = true;
}
public void RegisterPublicService<TContract>(
TContract implementation,
string? objectId = null,
string? pluginId = null,
params string[] notifyIds)
where TContract : class
{
RegisterPublicService(typeof(TContract), implementation, objectId, pluginId, notifyIds);
}
public void RegisterPublicService(
Type contractType,
object implementation,
string? objectId = null,
string? pluginId = null,
IEnumerable<string>? notifyIds = null)
{
ArgumentNullException.ThrowIfNull(contractType);
ArgumentNullException.ThrowIfNull(implementation);
var normalizedObjectId = objectId ?? string.Empty;
var normalizedNotifyIds = notifyIds?
.Where(id => !string.IsNullOrWhiteSpace(id))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray() ?? [];
lock (_gate)
{
if (_services.ContainsKey((contractType, normalizedObjectId)))
{
throw new InvalidOperationException(
$"Public IPC contract '{contractType.FullName}' with object id '{normalizedObjectId}' is already registered.");
}
CreateIpcJointMethod
.MakeGenericMethod(contractType)
.Invoke(null, [Provider, implementation, string.IsNullOrEmpty(normalizedObjectId) ? null : normalizedObjectId]);
_services[(contractType, normalizedObjectId)] = new PublicServiceEntry(
contractType,
implementation,
string.IsNullOrEmpty(normalizedObjectId) ? null : normalizedObjectId,
pluginId,
normalizedNotifyIds);
}
if (_started)
{
_ = NotifyCatalogChangedAsync();
}
}
public PublicIpcCatalogSnapshot GetCatalogSnapshot()
{
PublicIpcServiceDescriptor[] services;
lock (_gate)
{
services = _services.Values
.Select(entry => new PublicIpcServiceDescriptor(
entry.ContractType.FullName ?? entry.ContractType.Name,
entry.ContractType.Assembly.GetName().Name ?? string.Empty,
entry.ContractType.AssemblyQualifiedName,
entry.ObjectId,
entry.PluginId,
string.IsNullOrWhiteSpace(entry.PluginId),
entry.NotifyIds))
.OrderBy(entry => entry.PluginId ?? string.Empty, StringComparer.OrdinalIgnoreCase)
.ThenBy(entry => entry.ContractTypeName, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
var plugins = PluginDescriptorProvider()?.ToArray() ?? Array.Empty<PublicPluginDescriptor>();
return new PublicIpcCatalogSnapshot(services, plugins, DateTimeOffset.UtcNow);
}
public Task PublishStartupProgressAsync(
LanMountainDesktop.Shared.Contracts.Launcher.StartupProgressMessage message,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(message);
return NotifyAsync(IpcRoutedNotifyIds.LauncherStartupProgress, message, cancellationToken);
}
public Task PublishLoadingStateAsync(
LanMountainDesktop.Shared.Contracts.Launcher.LoadingStateMessage message,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(message);
return NotifyAsync(IpcRoutedNotifyIds.LauncherLoadingState, message, cancellationToken);
}
public async Task NotifyAsync<TPayload>(string notifyId, TPayload payload, CancellationToken cancellationToken = default)
where TPayload : class
{
ArgumentException.ThrowIfNullOrWhiteSpace(notifyId);
ArgumentNullException.ThrowIfNull(payload);
cancellationToken.ThrowIfCancellationRequested();
foreach (var peer in _connectedPeers.Values)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
var client = new JsonIpcDirectRoutedClientProxy(peer);
await client.NotifyAsync(notifyId, payload).ConfigureAwait(false);
}
catch
{
// Keep notification fan-out best-effort. Broken peers are cleaned by dotnetCampus.Ipc.
}
}
}
private Task NotifyCatalogChangedAsync()
{
return NotifyAsync(IpcRoutedNotifyIds.CatalogChanged, GetCatalogSnapshot());
}
private PublicIpcSessionInfo BuildSessionInfo()
{
return new PublicIpcSessionInfo(
PipeName,
IpcConstants.ProtocolVersion,
[
IpcConstants.Routes.SessionGetInfo,
IpcConstants.Routes.CatalogGet,
IpcRoutedNotifyIds.CatalogChanged,
IpcRoutedNotifyIds.LauncherStartupProgress,
IpcRoutedNotifyIds.LauncherLoadingState
],
StartedAt);
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_connectedPeers.Clear();
Provider.PeerConnected -= OnPeerConnected;
Provider.Dispose();
}
private void OnPeerConnected(object? sender, PeerConnectedArgs e)
{
if (_disposed)
{
return;
}
var peer = e.Peer;
_connectedPeers[peer.PeerName] = peer;
peer.PeerConnectionBroken -= OnPeerConnectionBroken;
peer.PeerConnectionBroken += OnPeerConnectionBroken;
}
private void OnPeerConnectionBroken(object? sender, IPeerConnectionBrokenArgs e)
{
if (sender is PeerProxy peer)
{
_connectedPeers.TryRemove(peer.PeerName, out _);
}
}
private sealed record PublicServiceEntry(
Type ContractType,
object Implementation,
string? ObjectId,
string? PluginId,
string[] NotifyIds);
}