多进程模型与 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 的部分能力有天然隔离需求:
- 权限隔离:证书信任、注册表写入、计划任务等操作需要管理员权限。让整个 UI 主进程提权是不可接受的,因此主进程以普通权限运行,并通过
StartSubProcessAsync(..., isAdministrator: true)拉起一个管理员权限的服务子进程,再通过 IPC 调用其能力。 - 稳定性隔离:加速器等重资源模块若崩溃,不应拖垮 UI 主进程。子进程崩溃后由主进程检测管道断线并通过守护委托重启。
- 代码复用:所有子进程宿主共用同一套启动/校验/连接代码,位于独立项目
BD.WTTS.Client.IPC中(IPCSubProcessService+IPCSubProcessServiceImpl),避免每个模块重复实现。
关键概念
| 概念 | 说明 |
|---|---|
| 主进程 | 运行 UI 的宿主进程,持有 IPC 服务端管道,是子进程的"父"管理者 |
| 子进程 | 以模块名(moduleName)标识的业务进程,由主进程 Process.Start 拉起 |
IpcProvider | dotnetCampus.Ipc.Pipes 的核心类型,同时承担服务端(监听管道)与客户端(连接对端)角色 |
PeerProxy | 对端代理,代表一条到对端管道的连接,可由 CreateIpcProxy<T> 包装成强类型远程接口 |
| 管道名 | 主进程管道名内嵌随机串 + 启动 tick + PID 信息;子进程服务端管道名为 {主进程管道名}_{moduleName} |
| 守护委托 | startSubProcesses 表中以模块名为键的 Func<IPCMainProcessService, ValueTask<Process?>>,用于断线后重启子进程 |
架构
架构要点:
- 双通道:主进程与每个子进程之间实际上存在两条管道连接。子进程在
RunAsync中既启动自己的服务端管道(GetClientPipeName(moduleName, pipeName)),又通过GetAndConnectToPeerAsync(pipeName)连接主进程管道。主进程通过GetServiceAsync<T>(moduleName)连接子进程服务管道以获得到子进程方向的代理;子进程通过GetService<T>()获得到主进程方向的代理。 - 契约与实现分离:子进程宿主代码(
IPCSubProcessService)位于独立项目,主进程通过 DI 使用IPCMainProcessService;两者仅共享接口与管道协议。 - partial 拆分:
IPCMainProcessServiceImpl是 partial 类,按IPCMainProcessServiceImpl.Toast.cs拆分 UI 相关能力,保持服务实现聚焦。
类型关系
主进程侧实现:IPCMainProcessServiceImpl
启动与管道命名
主进程在 Run() 中完成一次性初始化:
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 拼接):
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) 重试启动:
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,使子进程无需再自行探测数据目录):
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}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 编码,之后所有子进程复用同一字符串:
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_)提权启动,再按返回的 PIDProcess.GetProcessById取回Process对象。 - 关机防护:系统正在关机(
OSShuttingDownHelper.IsSystemShuttingDown())时直接返回null,避免关机期间残留孤儿子进程。 - 调试体验:
createNoWindow在 DEBUG 下为 false,子进程控制台窗口可见,便于查看输出。
子进程登记与守护:AddSubProcess / AddDaemonWithStartSubProcess
主进程维护三份并发安全的登记结构:
| 字段 | 类型 | 作用 |
|---|---|---|
subProcesses | ConcurrentDictionary<string, Process> | moduleName → 当前子进程 Process |
startSubProcesses | ConcurrentDictionary<string, Func<IPCMainProcessService, ValueTask<Process?>>> | moduleName → 重启委托(守护表) |
isReconnected | ConcurrentBag<string> | 已至少连接过一次的模块名,用于区分首连与重连 |
AddSubProcess 在登记新进程前会清理旧实例——若同名旧进程仍存活,则 KillEntireProcessTree() 杀掉整棵进程树再替换,保证一个模块名只对应一个活跃子进程:
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}_ 前缀还原模块名,并区分"首连"与"重连":
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>
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 做双向身份校验:
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:子进程建立双向通道
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}流程解读:
- 以
GetClientPipeName(moduleName, pipeName)生成{主进程管道名}_{moduleName}作为子进程自己的服务端管道名。 CreateIpcJoint<IPCSubProcessModuleService>(new IPCSubProcessModuleServiceImpl(this))在子进程侧注册反向控制关节:主进程可远程调用IPCSubProcessModuleService.Dispose(),其实现直接转发到IPCSubProcessServiceImpl.Dispose(),从而实现主进程优雅关闭子进程。configureIpcProvider委托允许具体子模块在启动服务前追加自己的 IPC 关节(如 ASF 插件注册自定义接口)。GetAndConnectToPeerAsync(pipeName)主动连接主进程管道,触发主进程的PeerConnected事件。- 持有的
tcs(TaskCompletionSource)在Dispose时TrySetResult(),用于宿主程序等待"被主进程要求退出"信号后结束主循环。
GetService<T>() 则把对主进程方向的 peer 包装成强类型代理:
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 的白名单判定逻辑:
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 的主体)。
核心流程
子进程启动与连接时序
断线守护重启流程
使用示例
主进程注册并启动一个受守护的子模块
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}调用方只需提供模块名与启动委托,即可获得"立即启动 + 注册进守护表"的一次性语义;后续断线时主进程依据守护表自动重启。
子进程获取主进程远程服务代理
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 代理,调用体验与本地接口一致。
主进程侧统一退出清理
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.AutoReconnectPeers | bool | 主进程侧显式设为 true | 允许对端断线后自动重连;子进程侧未设置(依赖库默认) |
IpcConfiguration.IpcLoggerProvider | Func<string, IpcLogger> | 无(两侧均显式提供) | 将 dotnetCampus.Ipc 日志桥接到应用 Microsoft.Extensions.Logging.ILoggerFactory |
IpcConfiguration.NamedPipeClientConnecting | 事件 | 两侧均订阅 | 管道客户端接入校验钩子,触发 ValidateNamedPipeClientConnection |
StartSubProcessAsync.fileName | string | 必填 | 子进程可执行文件路径 |
StartSubProcessAsync.isAdministrator | bool | false | 是否需要以管理员权限启动子进程 |
StartSubProcessAsync.configure | Action<ProcessStartInfo>? | null | 对 ProcessStartInfo 的追加配置委托 |
RunAsync.configureIpcProvider | Action<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 | 子进程直接退出,不做孤儿运行 |
| 主进程身份校验失败 | MainProcessIdIncorrect | fail-closed,拒绝连接 |
| 管道客户端非自家程序 | AllowConnection = false | 服务端主动拒绝接入 |
| 子进程运行中崩溃 | 主进程 PeerConnectionBroken | 依守护表 RetryAsync(3) 重启 |
| 提权启动失败 | StartProcessAsAdministratorAsync 返回默认值 | StartSubProcessAsync 返回 default |
| 关机期间启动 | OSShuttingDownHelper.IsSystemShuttingDown() | 返回 null,不启动 |
IpcProvider.Dispose 早于 StartServer | InvalidOperationException | 显式 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 序列化行为(未在本页读取,具体断言见该文件)。
扩展点
- 新增子模块:实现一个可执行宿主,调用
IPCSubProcessService.MainAsync(moduleName, pluginName, configureServices, configureIpcProvider, args),并通过configureIpcProvider在StartServer前用CreateIpcJoint<T>注册模块自己的远程接口。 - 主进程侧消费子模块:
Startup.Instance.TryGetPlugins返回的插件在OnPeerConnected(isReconnected)中获知子模块上线/重连;用GetServiceAsync<T>(moduleName)获取代理。 - 新增主进程远程能力:在
IPCMainProcessServiceImpl上以 partial 文件形式扩展(现有先例:IPCPlatformService.*系列、IPCMainProcessServiceImpl.Toast.cs),保持主文件聚焦于进程编排。 - 守护自定义:通过
AddDaemonWithStartSubProcessAsync提供自定义启动委托(可内嵌 Polly 策略),即可获得断线自动重启能力。