Repository Wiki
BeyondDimension/SteamTools

多进程模型与 IPC 通信

Watt Toolkit (Steam++) 采用"主进程 + 多子进程"的多进程模型,进程间通过基于命名管道(Named Pipe)的 dotnetCampus.Ipc 库进行双向 RPC 通信,并辅以双向 PID 校验、进程守护与断线重启机制。

目的与范围

本页覆盖 Watt Toolkit 客户端多进程架构的完整机制:

  • 主进程侧 IPC 服务 IPCMainProcessServiceImpl(服务端管道、子进程启动与守护、Peer 连接事件、远程代理获取)
  • 子进程侧 IPC 服务 IPCSubProcessServiceImpl 与共享契约 IPCSubProcessService(独立项目 BD.WTTS.Client.IPC,供子进程宿主复用)
  • 反向控制接口 IPCSubProcessModuleService(主进程远程调用子进程 Dispose)
  • 启动参数约定(pipeName / 主进程 PID / SubProcessArgumentIndex2Model)、管道命名规则、双向进程身份校验
  • 失败模式、并发治理与运维注意事项

以下相关主题有意留给兄弟页面,本页仅作指引:

  • 管理员特权子进程暴露的具体平台能力(IPCPlatformService 的 AppUpdate、Certificate、RunShell、Windows 注册表、计划任务等拆分文件)属于平台服务能力页面
  • 主进程 Toast 通知的具体展示逻辑(IPCMainProcessServiceImpl.Toast.cs、IPCToastService)属于 UI/通知页面
  • 各业务子模块(如加速器 Accelerator、ASF 插件)自身的启动细节属于对应插件页面

概述

为什么需要多进程模型

Watt Toolkit 的部分能力有天然隔离需求:

  1. 权限隔离:证书信任、注册表写入、计划任务等操作需要管理员权限。让整个 UI 主进程提权是不可接受的,因此主进程以普通权限运行,并通过 StartSubProcessAsync(..., isAdministrator: true) 拉起一个管理员权限的服务子进程,再通过 IPC 调用其能力。
  2. 稳定性隔离:加速器等重资源模块若崩溃,不应拖垮 UI 主进程。子进程崩溃后由主进程检测管道断线并通过守护委托重启。
  3. 代码复用:所有子进程宿主共用同一套启动/校验/连接代码,位于独立项目 BD.WTTS.Client.IPC 中(IPCSubProcessService + IPCSubProcessServiceImpl),避免每个模块重复实现。

关键概念

概念说明
主进程运行 UI 的宿主进程,持有 IPC 服务端管道,是子进程的"父"管理者
子进程以模块名(moduleName)标识的业务进程,由主进程 Process.Start 拉起
IpcProviderdotnetCampus.Ipc.Pipes 的核心类型,同时承担服务端(监听管道)与客户端(连接对端)角色
PeerProxy对端代理,代表一条到对端管道的连接,可由 CreateIpcProxy<T> 包装成强类型远程接口
管道名主进程管道名内嵌随机串 + 启动 tick + PID 信息;子进程服务端管道名为 {主进程管道名}_{moduleName}
守护委托startSubProcesses 表中以模块名为键的 Func<IPCMainProcessService, ValueTask<Process?>>,用于断线后重启子进程

架构

Loading diagram...

架构要点:

  • 双通道:主进程与每个子进程之间实际上存在两条管道连接。子进程在 RunAsync 中既启动自己的服务端管道(GetClientPipeName(moduleName, pipeName)),又通过 GetAndConnectToPeerAsync(pipeName) 连接主进程管道。主进程通过 GetServiceAsync<T>(moduleName) 连接子进程服务管道以获得到子进程方向的代理;子进程通过 GetService<T>() 获得到主进程方向的代理。
  • 契约与实现分离:子进程宿主代码(IPCSubProcessService)位于独立项目,主进程通过 DI 使用 IPCMainProcessService;两者仅共享接口与管道协议。
  • partial 拆分:IPCMainProcessServiceImpl 是 partial 类,按 IPCMainProcessServiceImpl.Toast.cs 拆分 UI 相关能力,保持服务实现聚焦。

类型关系

Loading diagram...

主进程侧实现:IPCMainProcessServiceImpl

启动与管道命名

主进程在 Run() 中完成一次性初始化:

csharp
1public void Run() 2{ 3 var tickCount64 = Environment.TickCount64; 4 var pid = Environment.ProcessId; 5 var ipcCfg = new IpcConfiguration 6 { 7 AutoReconnectPeers = true, // 允许重连 8 IpcLoggerProvider = _ => new IpcLogger_(loggerFactory, nameof(IPCMainProcessServiceImpl)), 9 }; 10 ipcCfg.NamedPipeClientConnecting += IPCSubProcessServiceImpl.ValidateNamedPipeClientConnection; 11 ipcProvider = new IpcProvider($"ipc_{_()}{tickCount64}{pid / 3}{pid % 3}", ipcCfg); 12 ConfigureServices(); 13 ipcProvider.StartServer(); 14 ipcProvider.PeerConnected += IpcProvider_PeerConnected;

管道名由四段拼接:ipc_ 前缀 + _() 随机段 + 系统 tick + PID 派生段(pid/3 与 pid%3 拼接):

csharp
1[MethodImpl(MethodImplOptions.AggressiveInlining)] 2static string _() 3{ 4#if DEBUG 5 return "000"; 6#else 7 return Random2.GenerateRandomString(randomChars: String2.LowerCaseLetters); 8#endif 9}

这一命名的设计意图有三层:(1) 内嵌启动时间与 PID 使管道名天然唯一,避免与上一次未完全退出的旧实例残留管道冲突;(2) RELEASE 下加入随机小写字母串,降低外部进程猜测管道名后伪造连接的概率;(3) DEBUG 下固定为 000,便于开发者手工定位与附加调试。

随后(仅 Windows),Run() 在后台任务中拉起管理员特权服务子进程,命令行格式为 -clt {CommandName} {args_PipeName} {pipeName} {args_ProcessId} {pid},并使用 Polly 策略 RetryAsync(3) 重试启动:

csharp
1await AddDaemonWithStartSubProcessAsync(IPlatformService.IPCRoot.moduleName, async _ => 2{ 3 return await Policy.HandleResult<Process?>(x => x == null) 4 .RetryAsync(3) 5 .ExecuteAsync(async () => 6 { 7 if (OSShuttingDownHelper.IsSystemShuttingDown()) 8 { 9 return null; 10 } 11 var process = await WindowsPlatformServiceImpl.StartAsAdministrator(processPath, arguments); 12 return process; 13 }); 14});

启动子进程:StartSubProcessAsync

StartSubProcessAsync 是主进程拉起任意子模块进程的统一入口。它构造 ProcessStartInfo,并通过命令行参数向子进程传递三项关键信息:主进程管道名、主进程 PID、Base64Url 编码的 SubProcessArgumentIndex2Model(包含 AppDataDirectory 与 CacheDirectory,使子进程无需再自行探测数据目录):

csharp
1var pipeName = ipcProvider.ThrowIsNull().IpcContext.PipeName; 2var pid = Environment.ProcessId; 3const bool useShellExecute = false; 4const bool createNoWindow = 5#if DEBUG 6 false; // 调试模式下显示窗口 7#else 8 true; 9#endif 10 11if (OSShuttingDownHelper.IsSystemShuttingDown()) 12{ 13 return null; 14}
csharp
1var psi = new ProcessStartInfo 2{ 3 FileName = fileName, 4 UseShellExecute = useShellExecute, 5 CreateNoWindow = createNoWindow, 6}; 7psi.ArgumentList.Add(pipeName); 8psi.ArgumentList.Add(pid.ToString()); 9psi.ArgumentList.Add(mSubProcessArgumentIndex2Model.Value); 10configure?.Invoke(psi); 11#if !MACOS 12DotNetRuntimeHelper.AddEnvironment(psi); 13#endif 14var nativeLibraryPath = Startup.NativeLibraryPath; 15if (!string.IsNullOrWhiteSpace(nativeLibraryPath)) 16{ 17 psi.Environment.TryAdd( 18 IPCSubProcessService.EnvKey_NativeLibraryPath, 19 nativeLibraryPath); 20}

mSubProcessArgumentIndex2Model 是 Lazy<string>,首次启动子进程时才序列化目录信息并做 Base64Url 编码,之后所有子进程复用同一字符串:

csharp
1readonly Lazy<string> mSubProcessArgumentIndex2Model = new(() => 2{ 3 var m = new SubProcessArgumentIndex2Model 4 { 5 AppDataDirectory = IOPath.AppDataDirectory, 6 CacheDirectory = IOPath.CacheDirectory, 7 }; 8 var b = Serializable.SMP2(m); 9 var s = b.Base64UrlEncode(); 10 return s; 11});

平台差异处理:

  • Linux:启动前先通过 /bin/bash -c 检查并 chmod +x 授予可执行权限。
  • 提权分支:isAdministrator == true 且当前主进程非特权(WindowsPlatformServiceImpl.IsPrivilegedProcess 为 false)时,不能直接 Process.Start,而是将整个 psi 用 Serializable.SMP2 序列化后交给 IPlatformService.Instance.StartProcessAsAdministratorAsync(psi_) 提权启动,再按返回的 PID Process.GetProcessById 取回 Process 对象。
  • 关机防护:系统正在关机(OSShuttingDownHelper.IsSystemShuttingDown())时直接返回 null,避免关机期间残留孤儿子进程。
  • 调试体验:createNoWindow 在 DEBUG 下为 false,子进程控制台窗口可见,便于查看输出。

子进程登记与守护:AddSubProcess / AddDaemonWithStartSubProcess

主进程维护三份并发安全的登记结构:

字段类型作用
subProcessesConcurrentDictionary<string, Process>moduleName → 当前子进程 Process
startSubProcessesConcurrentDictionary<string, Func<IPCMainProcessService, ValueTask<Process?>>>moduleName → 重启委托(守护表)
isReconnectedConcurrentBag<string>已至少连接过一次的模块名,用于区分首连与重连

AddSubProcess 在登记新进程前会清理旧实例——若同名旧进程仍存活,则 KillEntireProcessTree() 杀掉整棵进程树再替换,保证一个模块名只对应一个活跃子进程:

csharp
1void AddSubProcess(string moduleName, Process? process) 2{ 3 if (process == null) 4 return; 5 6 if (subProcesses.TryGetValue(moduleName, out var process1)) 7 { 8 bool hasExited = false; 9 try 10 { 11 hasExited = process1.HasExited; 12 } 13 catch 14 { 15 16 } 17 if (!hasExited) 18 { 19 try 20 { 21 subProcesses.TryRemove(moduleName, out var _); 22 process1.KillEntireProcessTree(); 23 } 24 catch 25 { 26 27 } 28 } 29 subProcesses[moduleName] = process; 30 } 31 else 32 { 33 subProcesses.TryAdd(moduleName, process); 34 } 35}

设计意图:以模块名为幂等键做单实例语义。HasExited 访问可能因权限不足抛异常,因此用空 catch 吞掉并视为"未退出",宁可误杀也不残留。AddDaemonWithStartSubProcess(moduleName, @delegate) 同步/异步两个重载在把启动委托注册进守护表后立刻执行一次启动并登记返回的 Process。

Peer 连接事件:IpcProvider_PeerConnected

主进程为每个子进程方向维护服务端连接事件。当某个子进程连上主进程管道时,通过 e.Peer.PeerName 去掉 {pipeName}_ 前缀还原模块名,并区分"首连"与"重连":

csharp
1async void IpcProvider_PeerConnected(object? sender, PeerConnectedArgs e) 2{ 3 var pipeName = ipcProvider.ThrowIsNull().IpcContext.PipeName; 4 var moduleName = e.Peer.PeerName.TrimStart($"{pipeName}_"); 5 6 var isReconnected = this.isReconnected.Contains(moduleName); 7 if (!isReconnected) this.isReconnected.Add(moduleName); 8 9 switch (moduleName) 10 { 11 case IPlatformService.IPCRoot.moduleName: 12 if (!isReconnected) 13 { 14 await IPlatformService.IPCRoot.SetIPC(this); 15 } 16 return; 17 } 18 19 if (Startup.Instance.TryGetPlugins(out var plugins)) 20 { 21 foreach (var plugin in plugins) 22 { 23 if (plugin.UniqueEnglishName != moduleName) 24 continue; 25 try 26 { 27 await plugin.OnPeerConnected(isReconnected); 28 } 29 catch (Exception ex) 30 { 31 logger.LogError(ex, "IpcProvider_PeerConnected fail."); 32 } 33 } 34 } 35}

事件处理器内部为 async void,每个插件的 OnPeerConnected 被独立 try/catch 包裹,单个插件异常不影响其余插件。管理员服务进程首连时调用 IPlatformService.IPCRoot.SetIPC(this) 把主进程 IPC 服务注入平台服务根,供后续跨进程调用(如 StartProcessAsAdministratorAsync)使用;重连时跳过,避免重复注入。

获取子进程远程代理:GetServiceAsync<T>

csharp
1public async ValueTask<T?> GetServiceAsync<T>(string moduleName) where T : class 2{ 3 if (ipcProvider == null) 4 return default; 5 6 try 7 { 8 var peerName = IPCSubProcessModuleService.Constants.GetClientPipeName( 9 moduleName, ipcProvider.IpcContext.PipeName); 10 var peer = await ipcProvider.GetAndConnectToPeerAsync(peerName); 11 12 if (peer != null) 13 { 14 var peerHashCode = peer.GetHashCode(); 15 if (peerHashCodes.Add(peerHashCode)) // peer 委托避免重复 += 16 { 17 peer.PeerConnectionBroken += async (_, _) => 18 { 19 if (disposedValue || ipcProvider == null) 20 return; // 被释放或者 ipc 提供者为 null 则跳过 21 // ... 断线守护重启逻辑 22 }; 23 } 24 // 返回强类型代理 25 } 26 } 27 ... 28}

注意 peerHashCodes(HashSet<int>)用于幂等挂接断线事件:同一个 peer 对象只会被挂接一次 PeerConnectionBroken 处理器,防止多次调用 GetServiceAsync 导致同一断线事件触发多次重启。断线回调中首先检查 disposedValue || ipcProvider == null——主进程正在退出时不再重启子进程。源码中另保留了一段被注释的 peerConnectionBrokenTimer 节流实现(3.7 秒窗口内多次断线不重启),说明断线风暴的防抖曾是一个实际痛点。

子进程侧实现:IPCSubProcessServiceImpl 与共享宿主

子进程通用启动入口 MainAsync

所有子进程宿主(加速器、管理员服务进程等)共享 IPCSubProcessService.MainAsync 静态方法完成引导,命令行参数约定为:args[0] = pipeName,args[1] = 主进程 PID。它依次校验参数、按 PID 反查主进程并调用 IPCSubProcessServiceImpl.CheckBePid 做双向身份校验:

csharp
1if (args.Length < 2) 2 return (int)CommandExitCode.EmptyArrayArgs; 3var pipeName = args[0]; 4if (string.IsNullOrWhiteSpace(pipeName)) 5 return (int)CommandExitCode.EmptyPipeName; 6if (!int.TryParse(args[1], out var pid)) 7 return (int)CommandExitCode.EmptyMainProcessId; 8if (!TryGetProcessById(pid, out var mainProcess)) 9 return (int)CommandExitCode.NotFoundMainProcessId; 10 11// 验证要连接的服务端进程是否为自己的程序 12var ckPid = IPCSubProcessServiceImpl.CheckBePid(mainProcess); 13if (!ckPid) 14 return (int)CommandExitCode.MainProcessIdIncorrect;

返回值是 CommandExitCode 枚举转 int,父进程可据此区分"参数为空 / 管道名为空 / PID 解析失败 / 主进程不存在 / 主进程身份不符"等失败原因,便于诊断启动失败。

RunAsync:子进程建立双向通道

csharp
1public async Task RunAsync(string moduleName, TaskCompletionSource tcs, string pipeName, 2 Action<IpcProvider>? configureIpcProvider = null) 3{ 4 this.tcs = tcs; 5 var ipcCfg = new IpcConfiguration 6 { 7 IpcLoggerProvider = _ => new IpcLogger_(loggerFactory, nameof(IPCSubProcessServiceImpl)), 8 }; 9 ipcCfg.NamedPipeClientConnecting += ValidateNamedPipeClientConnection; 10 ipcProvider = new IpcProvider(IPCSubProcessModuleService.Constants.GetClientPipeName(moduleName, pipeName), ipcCfg); 11 ipcProvider.CreateIpcJoint<IPCSubProcessModuleService>(new IPCSubProcessModuleServiceImpl(this)); 12 configureIpcProvider?.Invoke(ipcProvider); 13 ipcProvider.StartServer(); 14 15 peer = await ipcProvider.GetAndConnectToPeerAsync(pipeName); 16}

流程解读:

  1. 以 GetClientPipeName(moduleName, pipeName) 生成 {主进程管道名}_{moduleName} 作为子进程自己的服务端管道名。
  2. CreateIpcJoint<IPCSubProcessModuleService>(new IPCSubProcessModuleServiceImpl(this)) 在子进程侧注册反向控制关节:主进程可远程调用 IPCSubProcessModuleService.Dispose(),其实现直接转发到 IPCSubProcessServiceImpl.Dispose(),从而实现主进程优雅关闭子进程。
  3. configureIpcProvider 委托允许具体子模块在启动服务前追加自己的 IPC 关节(如 ASF 插件注册自定义接口)。
  4. GetAndConnectToPeerAsync(pipeName) 主动连接主进程管道,触发主进程的 PeerConnected 事件。
  5. 持有的 tcs(TaskCompletionSource)在 Dispose 时 TrySetResult(),用于宿主程序等待"被主进程要求退出"信号后结束主循环。

GetService<T>() 则把对主进程方向的 peer 包装成强类型代理:

csharp
1public T? GetService<T>() where T : class 2{ 3 if (ipcProvider != null && peer != null) 4 { 5 return ipcProvider.CreateIpcProxy<T>(peer); 6 } 7 return default; 8}

双向进程身份校验

安全性建立在双向校验上,任何一端都可以确认对端确实是"自家的程序":

  • 子进程校验主进程(MainAsync 中):按 PID 反查进程,调用 CheckBePid。
  • 主进程校验子进程(ValidateNamedPipeClientConnection):在命名管道客户端接入时,读取 NamedPipeClientConnectingEventArgs.ClientProcessId 再做 CheckBePid,不通过则 e.AllowConnection = false 拒绝连接。

CheckBePid 的白名单判定逻辑:

csharp
1internal static bool CheckBePid(Process proc) 2{ 3 if (proc.Id == Environment.ProcessId) 4 { 5 return true; 6 } 7 8 var thisPath = Environment.ProcessPath; 9 ArgumentException.ThrowIfNullOrWhiteSpace(thisPath); 10 thisPath = Path.GetFullPath(thisPath); 11 12 var procPath = proc.TryGetMainModule()?.FileName; 13 procPath = procPath == null ? null : Path.GetFullPath(procPath); 14 15 if (procPath != null) 16 { 17 if (thisPath == procPath) 18 { 19 return true; 20 } 21 22 var accProPath = Path.GetFullPath(Path.Combine(thisPath, "..", "modules", "Accelerator", 23#if WINDOWS 24 "Steam++.Accelerator.exe" 25#else 26 "Steam++.Accelerator" 27#endif 28 )); 29 if (procPath == accProPath) 30 { 31 return true; 32 } 33 34 if (AssemblyInfo.ValidateAssembly(procPath)) 35 { 36 return true; 37 } 38 } 39 40 return false; 41}

判定顺序:同 PID(自身)→ 可执行文件路径与自身完全一致 → 是 modules/Accelerator/Steam++.Accelerator(.exe) 加速器子模块 → 通过 AssemblyInfo.ValidateAssembly 的程序集级签名/身份校验。由于 Process.GetProcessById 与 TryGetMainModule 都可能因权限或竞态抛异常,TryGetProcessById 与外层 try/catch 将所有异常折叠为"校验失败",符合安全默认拒绝(fail-closed)原则。注意该校验仅在 Windows 生效(#if WINDOWS 包裹 ValidateNamedPipeClientConnection 的主体)。

核心流程

子进程启动与连接时序

Loading diagram...

断线守护重启流程

Loading diagram...

使用示例

主进程注册并启动一个受守护的子模块

csharp
1public Process? AddDaemonWithStartSubProcess(string moduleName, Func<IPCMainProcessService, Process?> @delegate) 2{ 3 ValueTask<Process?> StartSubProcessDelegate(IPCMainProcessService ipc) 4 { 5 var process = @delegate(ipc); 6 return ValueTask.FromResult(process); 7 } 8 9 if (startSubProcesses.ContainsKey(moduleName)) 10 { 11 startSubProcesses[moduleName] = StartSubProcessDelegate; 12 } 13 else 14 { 15 startSubProcesses.TryAdd(moduleName, StartSubProcessDelegate); 16 } 17 var process = @delegate?.Invoke(this); 18 AddSubProcess(moduleName, process); 19 return process; 20}

调用方只需提供模块名与启动委托,即可获得"立即启动 + 注册进守护表"的一次性语义;后续断线时主进程依据守护表自动重启。

子进程获取主进程远程服务代理

csharp
1public T? GetService<T>() where T : class 2{ 3 if (ipcProvider != null && peer != null) 4 { 5 return ipcProvider.CreateIpcProxy<T>(peer); 6 } 7 return default; 8}

子进程业务代码通过 IPCSubProcessService.Instance.GetService<T>() 即可拿到主进程侧接口的透明 RPC 代理,调用体验与本地接口一致。

主进程侧统一退出清理

csharp
1void Dispose(bool disposing) 2{ 3 if (!disposedValue) 4 { 5 if (disposing) 6 { 7 // 释放托管状态(托管对象) 8 try 9 { 10 ipcProvider?.Dispose(); 11 } 12 catch (InvalidOperationException) 13 { 14 // Unhandled exception. System.InvalidOperationException: 未启动之前,不能获取 IpcServerService 属性的值 15 // at dotnetCampus.Ipc.Pipes.IpcProvider.get_IpcServerService() 16 } 17 tcs?.TrySetResult(); 18 } 19 20 peer = null; 21 ipcProvider = null; 22 disposedValue = true; 23 } 24}

这是子进程实现中的 Dispose,关键点:tcs.TrySetResult() 通知宿主主循环退出;对 ipcProvider.Dispose() 的 InvalidOperationException 显式吞掉,因为 dotnetCampus.Ipc 在服务端从未 StartServer 的极端路径下访问 IpcServerService 会抛此异常(源码注释中保留了完整的异常栈作为证据)。

配置选项

选项类型默认值说明
IpcConfiguration.AutoReconnectPeersbool主进程侧显式设为 true允许对端断线后自动重连;子进程侧未设置(依赖库默认)
IpcConfiguration.IpcLoggerProviderFunc<string, IpcLogger>无(两侧均显式提供)将 dotnetCampus.Ipc 日志桥接到应用 Microsoft.Extensions.Logging.ILoggerFactory
IpcConfiguration.NamedPipeClientConnecting事件两侧均订阅管道客户端接入校验钩子,触发 ValidateNamedPipeClientConnection
StartSubProcessAsync.fileNamestring必填子进程可执行文件路径
StartSubProcessAsync.isAdministratorboolfalse是否需要以管理员权限启动子进程
StartSubProcessAsync.configureAction<ProcessStartInfo>?null对 ProcessStartInfo 的追加配置委托
RunAsync.configureIpcProviderAction<IpcProvider>?null子进程在 StartServer 前追加自定义 IPC 关节
EnvKey_NativeLibraryPath 环境变量string由 Startup.NativeLibraryPath 注入传给子进程的原生库搜索路径(非 macOS)
命令行 args[0]/args[1]/args[2]string—管道名 / 主进程 PID / Base64Url 的 SubProcessArgumentIndex2Model

API 参考

IPCMainProcessService.Run(): void

初始化主进程 IPC:生成唯一管道名、启动服务端、挂接 PeerConnected,并在 Windows 上后台拉起管理员特权服务子进程(Polly 重试 3 次)。应在主进程启动早期调用一次。

IPCMainProcessService.StartSubProcessAsync(fileName: string, isAdministrator: bool = false, configure: Action<ProcessStartInfo>? = null): ValueTask<Process?>

以统一参数约定启动子进程。

参数:

  • fileName (string):子进程可执行文件路径
  • isAdministrator (bool):是否需要管理员权限(仅 Windows 且主进程当前非特权时走提权分支)
  • configure (Action<ProcessStartInfo>?):追加修改启动信息的委托

返回: ValueTask<Process?> —— 启动的 Process;系统关机中或提权失败时返回 null/default。

行为细节: Linux 上先 chmod +x;自动注入 EnvKey_NativeLibraryPath 与 .NET 运行时环境(DotNetRuntimeHelper.AddEnvironment,非 macOS);DEBUG 下显示子进程窗口。

IPCMainProcessService.AddDaemonWithStartSubProcess(moduleName: string, delegate: Func<IPCMainProcessService, Process?>): Process?

同步注册守护委托并立即启动一次。返回启动的 Process(委托返回 null 时为 null)。

IPCMainProcessService.AddDaemonWithStartSubProcessAsync(moduleName: string, delegate: Func<IPCMainProcessService, ValueTask<Process?>>): ValueTask<Process?>

异步版本,语义同上。

IPCMainProcessService.GetServiceAsync<T>(moduleName: string): ValueTask<T?> where T : class

连接模块的子进程服务管道并返回强类型远程代理。首次调用会为该 peer 挂接一次 PeerConnectionBroken 守护处理器;ipcProvider 未初始化时返回 default。

IPCSubProcessService.RunAsync(moduleName: string, tcs: TaskCompletionSource, pipeName: string, configureIpcProvider: Action<IpcProvider>? = null): Task

在子进程中启动 IPC:建立 {pipeName}_{moduleName} 服务端管道、注册反向控制关节 IPCSubProcessModuleService、连接主进程管道。tcs 在 Dispose 时完成,用于通知宿主退出。

IPCSubProcessService.GetService<T>(): T? where T : class

返回主进程接口的远程代理;未就绪(ipcProvider 或 peer 为 null)时返回 null。

IPCSubProcessModuleService.Dispose(): void(跨进程调用)

主进程远程调用的反向控制接口,实现转发到 IPCSubProcessServiceImpl.Dispose(),用于优雅终止子进程。

IPCSubProcessService.MainAsync(moduleName, pluginName, configureServices, configureIpcProvider, params string[] args): Task<int>

子进程宿主通用入口。返回 CommandExitCode 的 int 值:EmptyArrayArgs / EmptyPipeName / EmptyMainProcessId / NotFoundMainProcessId / MainProcessIdIncorrect 或 0(成功路径)。

失败模式、边界情况与并发

失败模式与退出码

失败场景表现处理
子进程参数不足/为空MainAsync 返回 EmptyArrayArgs/EmptyPipeName父进程按退出码诊断
主进程 PID 无效或已退出NotFoundMainProcessId子进程直接退出,不做孤儿运行
主进程身份校验失败MainProcessIdIncorrectfail-closed,拒绝连接
管道客户端非自家程序AllowConnection = false服务端主动拒绝接入
子进程运行中崩溃主进程 PeerConnectionBroken依守护表 RetryAsync(3) 重启
提权启动失败StartProcessAsAdministratorAsync 返回默认值StartSubProcessAsync 返回 default
关机期间启动OSShuttingDownHelper.IsSystemShuttingDown()返回 null,不启动
IpcProvider.Dispose 早于 StartServerInvalidOperationException显式 catch 吞掉(源码注释保留异常栈)

并发治理

  • subProcesses、startSubProcesses 使用 ConcurrentDictionary,isReconnected 使用 ConcurrentBag,支撑 async void 事件处理器与后台启动任务的并发访问。
  • peerHashCodes(HashSet<int>)在 GetServiceAsync 中以"添加即幂等挂接"的技巧避免同一 peer 重复订阅断线事件——但该 HashSet 本身非线程安全,属于已知取舍。
  • AddSubProcess 中先 TryRemove 再杀整树再索引赋值,非原子序列依赖 ConcurrentDictionary 的索引器写保证最终一致。
  • 子进程侧 tcs 使用 TrySetResult() 而非 SetResult(),天然容忍 Dispose 被多次调用。

设计意图小结

  • 单实例语义:模块名作为幂等键,同名旧进程存活即被 KillEntireProcessTree 清除,杜绝重复子进程。
  • 双向信任:两端都校验对端可执行文件身份(路径一致 / 加速器模块 / AssemblyInfo.ValidateAssembly),管道名猜测攻击无效。
  • 进程树级清理:杀进程使用 KillEntireProcessTree() 而非 Kill(),防止子进程再派生的孙进程成为孤儿。
  • 退出协同:主进程退出路径(disposedValue 检查)与子进程 tcs 信号共同保证优雅双向关闭;系统关机由 OSShuttingDownHelper 单独拦截。

性能与运维注意事项

  • 日志桥接:两侧均通过 IpcLogger_ 适配器把 IPC 库日志映射到 Microsoft.Extensions.Logging,Debug 及以下统一折叠为 Debug 级别,其余级别直接枚举数值转换。生产排障时开启对应 ILogger 级别即可看到 IPC 内部日志。
  • DEBUG 与 RELEASE 差异:DEBUG 下管道随机段固定为 000、显示子进程窗口、输出 LogError 级别的调试日志(如 "收到 {peerName} 连接");RELEASE 下随机化管道名、隐藏窗口。跨版本排障时需注意这些差异。
  • 重试策略:管理员子进程启动使用 Polly RetryAsync(3);断线守护重启同样走重试路径。注释中被禁用的 3.7 秒断线节流计时器提示:若子进程频繁崩溃,重启风暴是潜在风险点。
  • 原生库路径传递:非 macOS 平台通过环境变量 EnvKey_NativeLibraryPath 传递原生库搜索路径;子进程侧在 LIB_CLIENT_IPC 条件下监听 AppDomain.CurrentDomain.AssemblyLoad 并自定义 DllImport 解析(源码可见 CurrentDomain_AssemblyLoad 与 GetLibraryFileName 逻辑)。
  • 测试参考:src/BD.WTTS.UnitTest/IpcSerializableTest.cs 覆盖 IPC 序列化行为(未在本页读取,具体断言见该文件)。

扩展点

  1. 新增子模块:实现一个可执行宿主,调用 IPCSubProcessService.MainAsync(moduleName, pluginName, configureServices, configureIpcProvider, args),并通过 configureIpcProvider 在 StartServer 前用 CreateIpcJoint<T> 注册模块自己的远程接口。
  2. 主进程侧消费子模块:Startup.Instance.TryGetPlugins 返回的插件在 OnPeerConnected(isReconnected) 中获知子模块上线/重连;用 GetServiceAsync<T>(moduleName) 获取代理。
  3. 新增主进程远程能力:在 IPCMainProcessServiceImpl 上以 partial 文件形式扩展(现有先例:IPCPlatformService.* 系列、IPCMainProcessServiceImpl.Toast.cs),保持主文件聚焦于进程编排。
  4. 守护自定义:通过 AddDaemonWithStartSubProcessAsync 提供自定义启动委托(可内嵌 Polly 策略),即可获得断线自动重启能力。

相关链接

Sources

(3 files)
src/BD.WTTS.Client.IPC/Services
src/BD.WTTS.Client.IPC/Services.Implementation
src/BD.WTTS.Client/Services.Implementation/IPC