diff --git a/CHANGELOG.md b/CHANGELOG.md index 53a7e6c4c..e09ac4920 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ * HTTP 请求管线正确性与响应一致性(C458,GFX-1180,GitHub #926):ProtoBuf 成功响应写出前更正 Content-Type 为 `application/x-protobuf`(原恒为 JSON,按媒体类型分派的客户端/网关解析二进制体失败);参数字典改 OrdinalIgnoreCase 并同步把 Query 提取改 TryAdd——大小写差异同名键(Query↔Body、Query↔Query)一律显式 `ParamErr` 拒绝,消除「被校验/被签名的 Query 值被 Body 静默覆盖」窗口;非 JSON 对象 Body(null 字面量/数组/标量/空体)以参数错误拒绝(新键 `Network.Http.RequestBodyNotJsonObject`),不再落入顶层 catch 记服务器错误日志;非 GET 缺 Content-Type 改统一 JSON envelope;DataAnnotations 校验失败业务码由字面量 400 统一为 `GameHttpStatusCode.ParamErr`;Swagger 路由匹配改末段精确比较(互为后缀命令不再错配文档,驼峰路由可匹配)且 GET 参数必填性跟随 `RequiredAttribute`;AOP 拒绝且未自行写响应时管线回写校验失败 envelope(契约写入 `IHttpAopHandler.Run`);`CheckSign` 缺 sign/timestamp 改本地化消息。对外破坏性:依赖 `code == 400` 判定校验失败的客户端改判 `code == 13`;`HttpStatusMessage.Success`/`NotFoundCommand`/`CheckFailedCommand` 及孤儿键 `Network.Http.NotFoundCommand`/`CheckFailedCommand` 删除(公共 API,下游如有引用需迁移)。 * Utility 安全修复:配置凭据脱敏、解压炸弹上限、冲突异常 Secret 脱敏与压缩失败显式抛出(C433,GFX-1173,GitHub #919):①`AppSetting.ToString/ToFormatString` 经浅拷贝掩码后序列化——`DataBasePassword`/`HttpSignSecret`/`InterNodeAuthSecret`/`HttpsCertPassword` 非空值输出 `***`(null/空串原样保留区分「未配置」),四个凭据不再随启动/停止日志明文外发;②`CompressionHelper.Decompress` 新增 `maxOutputLength` 上限重载(旧签名转发 const 默认 64MB),膨胀过程内超限即抛 `InvalidDataException`——Network `DefaultMessageDecompressHandler` 增构造参数上限、RemoteMessaging `IMessageCompressionAlgorithm.Decompress` 接口加参(const 默认值,源码兼容;`DefaultMessageCodec` 显式传 `_maxDecompressedSize`),事后长度检查降级为兜底防线,1MB 高压缩比帧不再可膨胀至 GB 级托管内存;③`SettingFieldConflict` 敏感字段判定扩展 Secret/Token 子串——多 Role 配置冲突异常不再明文携带 `HttpSignSecret`/`InterNodeAuthSecret`;④`RandomHelper` 类文档声明双随机源分级(基础接口 `Random.Shared` 非加密 PRNG 禁用于令牌/验证码/防作弊场景,安全场景用权重系列 CSPRNG 或 `RandomNumberGenerator`);⑤落地已归档 C349 既定方案:`Compress` 异常原样抛出、`Decompress` 对非法/损坏/截断数据统一抛 `InvalidDataException`(含 SharpZipLib 截断流死循环主动判定),删除「失败静默返回原始字节」回退与 `CompressFailed` 本地化键;⑥`FP.Sqrt/Acos` 越界异常构造器实参换序(本地化消息进 `Message`、`"x"` 进 `ParamName`,修复前 `ParamName == null`)。公共 API 行为变更经本条披露;解压炸弹/脱敏/截断/参数名均有回归用例锁定。 +* Core 热更注册契约、装载信任与行为一致性 13 项(C437,GFX-1177,GitHub #923):`HotfixModule.GetComponentTypeFromAgent` 统一守卫五处泛型基类反推(契约异常替代 IndexOutOfRangeException)并使 `AddEvent` 组件→Actor 反查带上下文抛出;RPC 注册面工厂化(`_rpcHandlerMap` 值改 `Func`,注册期 CreateFactory 校验、同请求重复注册 fail-fast)与 `MessageRpcMappingAttribute` 改 `(Type, Type)` 形态(原接口实例参数因 CS0181 从未可用,属破坏性修正,零存量消费者);消息号冲突 fail-fast;HTTP 命令字典改 OrdinalIgnoreCase 且全小写命令单次注册;热更依赖装载随模块可收集上下文同生共死(判重只看默认 ALC,热重载亦装载);热更装载 SHA-256 哈希校验灰度开关(`HotfixVerifyEnabled` 默认 false + `HotfixLoadOptions.ExpectedSha256`,校验在 ALC 创建前,仅覆盖主程序集);`DelayAsync(long)` 改 Unix 毫秒时间戳 UTC 同基解释;跨天第二段独立计时;组件 RPC 超时回写 TimeOut 响应(孤儿响应终态归还);ActorLimit 收敛为真实可用(死代码删除、在飞登记 EndCall 移除含空集合回收、`ActorLimitRule` 配置化默认 None、8 调用点拒绝异常化、`WorkWrapper.CallerActorId`);未登录玩家消息删除随机 ActorId 回退(Fatal+关通道);`ComponentRegister.InitAsync` 加锁修复并行初始化字典损坏;六本地化键双语。 * Core 回收链路数据与稳定性修复(C436,GFX-1176,GitHub #922):`Actor.OnRecycleAsync` 在回收回调后删除 `ScheduleIdSet` 中剩余的 Quartz 任务并清空集合,三条回收路径(空闲回收 / `RemoveAsync` / `RemoveAllAsync`)均覆盖;定时任务触发改为 `isNew: false` 反查,目标 Actor 不在内存时不再重建 Actor(消除周期任务复活已回收 Actor 的循环),记 Warning 并删除该任务。`BaseComponent.InactiveAsync` 以 try/finally 保证 `IsActive` 落为 false,`Actor.InactiveAsync` 逐组件隔离异常(Fatal 日志后继续),单个组件反激活失败不再让 Actor 永驻内存,也不再打断 `RemoveAllAsync` 关服流程。显式移除前对有未落库修改的 Actor 强制回存(失败记 Fatal 不阻断移除)。`StateComponent.TimerSaveAsync` 单个状态类型回存失败不再中断整轮,日志级别 Info → Error。`ActorManager.RemoveAsync` 返回真实完成任务(await 返回即回存 / 反激活 / 回收回调均已执行,隔离 Actor 返回异常任务)并同步删除 `ActiveTimeDic` 条目。行为变化:依赖「定时器唤醒离线 Actor」的业务需在触发前自行加载 Actor;组件反激活异常不再向 `RemoveAllAsync` 调用方抛出。回归 11 例(A/B:还原生产改动后 11/11 必败)。 * 架构分析器守卫三项收口(C429/C430,GFX-1170,GitHub #916):GFX0016 候选集从仅 Class 放宽为全部命名类型种类——struct/interface/record 声明面引用 `MongoDbService`/`PostgreSqlDbService`/`MultiDbRegistry` 不再漏报(自行注册 NamedType 符号分析,实现层白名单与豁免不变);GFX0001 补抽象类豁免(与 GFX0005/0008/0009 一致,非 Apps 程序集的抽象中间状态类不再被误拦);生成 Wrapper 豁免改为「`GeneratedCodeAttribute` 标记(tool 名精确匹配)+ `.g.cs` 文件路径 + 仅 Hotfix 内名称后缀兜底」三段判定,Hotfix 之外手写 `ComponentAgentWrapper` 后缀类不再同时逃过 GFX0005/GFX0014;WrapperGenerator 生成类头追加 `GeneratedCodeAttribute` 标记(与分析器 `WrapperGeneratorToolName` 同值契约)。 * 入队与定时撮合解耦(C406,GFX-1104):删除每次入队的全量撮合及最新票据扫描,复用 `matchmaker-run` 按宿主调度节奏批量处理队列(默认 1000ms)。单轮撮合算法保持不变;桥测试覆盖四名玩家单批次形成两组及批次前取消不参与分配。 diff --git a/GameFrameX.Core/Actors/Actor.cs b/GameFrameX.Core/Actors/Actor.cs index 80f78a5fb..152a8eee5 100644 --- a/GameFrameX.Core/Actors/Actor.cs +++ b/GameFrameX.Core/Actors/Actor.cs @@ -33,6 +33,7 @@ using GameFrameX.Core.Abstractions.Timer; using GameFrameX.Core.Actors.Impl; using GameFrameX.Core.Components; +using GameFrameX.Core.Hotfix; using GameFrameX.Core.Timer; using GameFrameX.Foundation.Logger; using GameFrameX.Foundation.Localization.Core; @@ -154,7 +155,7 @@ public List GetActiveComponentAgents() /// 返回指定类型的组件代理实例 public async Task GetComponentAgentAsync(Type agentType, bool isNew = true) { - var compType = agentType.BaseType.GetGenericArguments()[0]; + var compType = HotfixModule.GetComponentTypeFromAgent(agentType); BaseComponent component; if (isNew) { diff --git a/GameFrameX.Core/Actors/ActorManager.cs b/GameFrameX.Core/Actors/ActorManager.cs index d3295234a..745e22375 100644 --- a/GameFrameX.Core/Actors/ActorManager.cs +++ b/GameFrameX.Core/Actors/ActorManager.cs @@ -532,6 +532,9 @@ async Task Work() var globalCost = (DateTime.Now - begin).TotalMilliseconds; // Localization: Core.ActorManager.GlobalCompCrossDayCompleteLog - ActorManager.CrossDayAsync 全局comp跨天完成 , 耗时: {0}ms , 消息: 全局comp跨天完成 耗时:{1}ms LogHelper.Info(LocalizationService.GetString(Localization.Keys.Core.ActorManager.GlobalCompCrossDayCompleteLog, globalCost, globalCost)); + + // 第二段(非玩家段)投递前重置等待基准:跨天预算按段独立计量,不复用第一段已消耗的 begin + begin = DateTime.Now; a = 0; b = 0; foreach (var actor in ActorMap.Values) @@ -562,7 +565,7 @@ async Task Work() await Task.Delay(TimeSpan.FromMilliseconds(10)); } - var otherCost = (DateTime.Now - begin).TotalMilliseconds - globalCost; + var otherCost = (DateTime.Now - begin).TotalMilliseconds; // Localization: Core.ActorManager.NonPlayerCompCrossDayCompleteLog - ActorManager.CrossDayAsync 非玩家comp跨天完成 , 耗时: {0}ms , 消息: 非玩家comp跨天完成 耗时:{1}ms LogHelper.Info(LocalizationService.GetString(Localization.Keys.Core.ActorManager.NonPlayerCompCrossDayCompleteLog, otherCost, otherCost)); } diff --git a/GameFrameX.Core/Actors/Impl/ActorLimit.cs b/GameFrameX.Core/Actors/Impl/ActorLimit.cs index 37593cb81..1db45a3a1 100644 --- a/GameFrameX.Core/Actors/Impl/ActorLimit.cs +++ b/GameFrameX.Core/Actors/Impl/ActorLimit.cs @@ -58,7 +58,6 @@ public enum RuleType private static IRule _rule; - private static readonly Dictionary LevelDic = new(128); /// /// 初始化 @@ -68,30 +67,13 @@ public static void Init(RuleType type) { switch (type) { - /*case RuleType.ByLevel: - { - _rule = new ByLevelRule(); - try - { - foreach (ActorTypeLevel foo in Enum.GetValues(typeof(ActorTypeLevel))) - { - var actorType = (ushort)foo; - LevelDic.Add(actorType, (int)foo); - } - } - catch (Exception exception) - { - LogHelper.Fatal(exception); - throw; - } - } - break;*/ case RuleType.NoBidirectionCall: { _rule = new NoBidirectionCallRule(); } break; case RuleType.None: + _rule = null; break; default: // Localization: Core.ActorLimit.UnsupportedRuleType - 不支持的rule类型:{0} @@ -101,75 +83,54 @@ public static void Init(RuleType type) } /// - /// 是否允许调用 + /// 是否允许调用:规则激活时检查并在放行时登记「self → target」在飞调用。 /// /// 目标 /// 返回是否调用 public static bool AllowCall(long target) { - if (_rule != null) + var rule = _rule; + if (rule != null) { - return _rule.CanCall(target); + return rule.CanCall(target); } return true; } - private interface IRule + /// + /// 结束一次在飞调用:工作单元终态(执行完毕/入队失败/取消/超时放弃)时移除登记,内存天然有界。 + /// + /// 调用方Actor ID + /// 目标Actor ID + public static void EndCall(long self, long target) { - bool CanCall(long target); + _rule?.EndCall(self, target); } - - #region ByLevelRule - - private class ByLevelRule : IRule + private interface IRule { - /// - /// 判断是否允许调用 - /// - /// 目标 - /// - bool IRule.CanCall(long target) - { - var actorId = RuntimeContext.CurrentActor; - // 从其他线程抛到actor,不涉及入队行为 - if (actorId == 0) - { - return true; - } - - var currentType = ActorIdGenerator.GetActorType(actorId); - var targetType = ActorIdGenerator.GetActorType(target); - if (LevelDic.TryGetValue(targetType, out var targetValue) && LevelDic.TryGetValue(currentType, out var currentValue)) - { - //等级高的不能【等待】调用等级低的 - if (currentValue > targetValue) - { - // Localization: Core.ActorLimit.InvalidCallPath - 不合法的调用路径:{0}==>{1} - LogHelper.Error(LocalizationService.GetString(Localization.Keys.Core.ActorLimit.InvalidCallPath, currentType, targetType)); - return false; - } - } + bool CanCall(long target); - return true; - } + void EndCall(long self, long target); } - #endregion - #region NoBidirectionCallRule private class NoBidirectionCallRule : IRule { - private readonly ConcurrentDictionary> _crossDic = new(); + /// + /// 并发在飞调用计数登记:self → { target → 在飞计数 }。 + /// 同一 caller→target 的并发调用按次数累加、按终态递减,全部结束后回收边与外壳键,内存天然有界。 + /// + private readonly ConcurrentDictionary> _crossDic = new(); /// /// 是否允许调用 /// /// 目标 - /// 返回是否调用 + /// bool IRule.CanCall(long target) { var actorId = RuntimeContext.CurrentActor; @@ -198,12 +159,52 @@ private bool CanCall(long self, long target) return false; } - var selfSet = _crossDic.GetOrAdd(self, k => new ConcurrentDictionary()); - selfSet.TryAdd(target, false); + var selfSet = _crossDic.GetOrAdd(self, k => new ConcurrentDictionary()); + var counter = selfSet.GetOrAdd(target, k => new InFlightCounter()); + Interlocked.Increment(ref counter.Count); return true; } + + /// + /// 终态递减在飞计数:计数归零时回收边,边集合清空后原子回收外层键。 + /// 调用方以工作单元携带的登记所有权标志守卫——未登记路径(discard/checkLock 跳过、Tell)不得递减他人计数。 + /// + /// 调用方Actor ID + /// 目标Actor ID + void IRule.EndCall(long self, long target) + { + // IO 上下文与自调用未登记,无需移除 + if (self == 0 || self == target) + { + return; + } + + if (_crossDic.TryGetValue(self, out var set) && set.TryGetValue(target, out var counter)) + { + if (Interlocked.Decrement(ref counter.Count) <= 0) + { + // 仅当仍为当前计数实例时移除边(原子引用比较):并发重入者会命中同实例并使计数大于零, + // 不会进入本分支;被并发替换的实例残留由其自身路径的终态递减清理 + set.TryRemove(new KeyValuePair(target, counter)); + + if (set.IsEmpty) + { + _crossDic.TryRemove(new KeyValuePair>(self, set)); + } + } + } + } + + /// + /// 在飞调用计数器(同 caller→target 并发调用按次数累加,首个终态不再清边)。 + /// + private sealed class InFlightCounter + { + /// 当前在飞计数。 + public int Count; + } } #endregion -} \ No newline at end of file +} diff --git a/GameFrameX.Core/Actors/Impl/WorkWrapper.cs b/GameFrameX.Core/Actors/Impl/WorkWrapper.cs index 77d21cbd5..7541671a5 100644 --- a/GameFrameX.Core/Actors/Impl/WorkWrapper.cs +++ b/GameFrameX.Core/Actors/Impl/WorkWrapper.cs @@ -52,6 +52,18 @@ public abstract class WorkWrapper /// public long CallChainId { get; set; } + /// + /// 调用方Actor ID(入队时捕获 RuntimeContext.CurrentActor)。 + /// ActorLimit 在飞登记移除用:工作单元终态时以其定位「caller → target」登记项。 + /// + public long CallerActorId { get; set; } + + + /// + /// 本工作单元是否持有 ActorLimit 在飞登记(入队时经 AllowCall 放行且实际登记为 true)。 + /// 未登记路径(discard/checkLock 跳过、Tell)不得在终态移除他人登记。 + /// + public bool LimitRegistered { get; set; } /// /// 调用方传入的取消令牌。 /// 入队阶段用于取消排队;执行阶段与超时合并为执行期取消源(见 )。 diff --git a/GameFrameX.Core/Actors/Impl/WorkerActor.cs b/GameFrameX.Core/Actors/Impl/WorkerActor.cs index a2ed9075f..c215639ff 100644 --- a/GameFrameX.Core/Actors/Impl/WorkerActor.cs +++ b/GameFrameX.Core/Actors/Impl/WorkerActor.cs @@ -127,6 +127,28 @@ private static InvalidOperationException CreateQuarantinedException(WorkWrapper return new InvalidOperationException(LocalizationService.GetString(Localization.Keys.CoreExceptions.WorkerActor.QuarantinedWithTrace, wrapper.Owner?.Id, GetTraceSafely(wrapper))); } + /// + /// 构建限流规则拒绝异常:拒绝不得伪装成功,等待方必须感知工作单元未执行。 + /// + private InvalidOperationException CreateCallRejectedException() + { + // Localization: CoreExceptions.WorkerActor.CallRejectedByLimitRule - WorkerActor工作调用被限流规则拒绝,调用方ActorId:{0} 目标ActorId:{1} + return new InvalidOperationException(LocalizationService.GetString(Localization.Keys.CoreExceptions.WorkerActor.CallRejectedByLimitRule, RuntimeContext.CurrentActor, Id)); + } + + + /// + /// 释放本工作单元持有的 ActorLimit 在飞登记:仅入队时实际登记过的单元递减计数, + /// 未登记路径(discard/checkLock 跳过、Tell)不移除他人登记。 + /// + private static void ReleaseLimitRegistration(WorkWrapper wrapper) + { + if (wrapper.LimitRegistered) + { + ActorLimit.EndCall(wrapper.CallerActorId, wrapper.Owner?.Id ?? 0); + } + } + /// /// Actor的唯一标识 /// @@ -165,6 +187,8 @@ private async Task InnerRunAsync(WorkWrapper wrapper) { if (IsQuarantined) { + // 工作单元终态:移除本单元持有的 ActorLimit 在飞登记(隔离分支在 try/finally 之外,须自行移除) + ReleaseLimitRegistration(wrapper); wrapper.TrySetException(CreateQuarantinedException(wrapper)); return; } @@ -211,6 +235,8 @@ private async Task InnerRunAsync(WorkWrapper wrapper) } finally { + // 工作单元终态:移除本单元持有的 ActorLimit 在飞登记(含超时孤儿与隔离放弃路径) + ReleaseLimitRegistration(wrapper); // 孤儿被放弃跟踪时不得释放其执行期取消源(可能仍被持有注册) wrapper.EndExecution(!abandoned); } @@ -299,6 +325,11 @@ private static void CompleteSendResult(Task sendTask, WorkWrapper wrapper, private static void CompleteRejectedPost(WorkWrapper wrapper, bool completeOnFailure) { + // 入队失败即工作单元终态:仅本单元登记过的在飞计数才可移除(未登记路径不得递减他人计数) + if (wrapper.LimitRegistered) + { + ActorLimit.EndCall(wrapper.CallerActorId, wrapper.Owner?.Id ?? 0); + } var exception = CreateQueueRejectedException(wrapper); if (completeOnFailure) { @@ -313,6 +344,11 @@ private static void CompleteRejectedPost(WorkWrapper wrapper, bool completeOnFai private static void CompleteCanceledPost(WorkWrapper wrapper, CancellationToken cancellationToken, bool completeOnFailure) { + // 入队失败即工作单元终态:仅本单元登记过的在飞计数才可移除(未登记路径不得递减他人计数) + if (wrapper.LimitRegistered) + { + ActorLimit.EndCall(wrapper.CallerActorId, wrapper.Owner?.Id ?? 0); + } if (completeOnFailure) { wrapper.TrySetCanceled(cancellationToken); @@ -326,6 +362,11 @@ private static void CompleteCanceledPost(WorkWrapper wrapper, CancellationToken private static void CompleteFailedPost(WorkWrapper wrapper, Exception exception, bool completeOnFailure) { + // 入队失败即工作单元终态:仅本单元登记过的在飞计数才可移除(未登记路径不得递减他人计数) + if (wrapper.LimitRegistered) + { + ActorLimit.EndCall(wrapper.CallerActorId, wrapper.Owner?.Id ?? 0); + } if (completeOnFailure) { wrapper.TrySetException(exception); @@ -359,7 +400,7 @@ public static long NextChainId() /// /// 要执行的同步Action工作单元 /// 用于追踪任务调用链的唯一标识符 - /// 是否忽略调试模式下的调用限制检查,true表示强制执行 + /// 是否跳过调用限制检查,true表示跳过 /// 任务执行的超时时间(毫秒),默认为-1,将采用配置时间ActorQueueTimeOut /// 用于取消任务的令牌 /// 返回一个Task对象,表示异步操作的完成状态 @@ -370,9 +411,9 @@ public Task EnqueueAsync(Action work, long callChainId, bool discard = false, in return Task.FromException(CreateQuarantinedException()); } - if (!discard && GlobalSettings.CurrentSetting.IsDebug && !ActorLimit.AllowCall(Id)) + if (!discard && !ActorLimit.AllowCall(Id)) { - return Task.CompletedTask; + return Task.FromException(CreateCallRejectedException()); } if (timeOut <= 0) @@ -385,6 +426,8 @@ public Task EnqueueAsync(Action work, long callChainId, bool discard = false, in Owner = this, TimeOut = timeOut, CallChainId = callChainId, + CallerActorId = RuntimeContext.CurrentActor, + LimitRegistered = RuntimeContext.CurrentActor != 0 && RuntimeContext.CurrentActor != Id, }; SendToActionBlock(at, cancellationToken, true); return at.Tcs.Task; @@ -396,7 +439,7 @@ public Task EnqueueAsync(Action work, long callChainId, bool discard = false, in /// 函数返回值的类型 /// 要执行的同步Func工作单元 /// 用于追踪任务调用链的唯一标识符 - /// 是否忽略调试模式下的调用限制检查,true表示强制执行 + /// 是否跳过调用限制检查,true表示跳过 /// 任务执行的超时时间(毫秒),默认为-1,将采用配置时间ActorQueueTimeOut /// 用于取消任务的令牌 /// 返回一个Task对象,表示异步操作的完成状态和结果 @@ -407,9 +450,9 @@ public Task EnqueueAsync(Func work, long callChainId, bool discard = fa return Task.FromException(CreateQuarantinedException()); } - if (!discard && GlobalSettings.CurrentSetting.IsDebug && !ActorLimit.AllowCall(Id)) + if (!discard && !ActorLimit.AllowCall(Id)) { - return Task.FromResult(default(T)); + return Task.FromException(CreateCallRejectedException()); } if (timeOut <= 0) @@ -422,6 +465,8 @@ public Task EnqueueAsync(Func work, long callChainId, bool discard = fa Owner = this, TimeOut = timeOut, CallChainId = callChainId, + CallerActorId = RuntimeContext.CurrentActor, + LimitRegistered = RuntimeContext.CurrentActor != 0 && RuntimeContext.CurrentActor != Id, }; SendToActionBlock(at, cancellationToken, true); return at.Tcs.Task; @@ -432,7 +477,7 @@ public Task EnqueueAsync(Func work, long callChainId, bool discard = fa /// /// 要执行的异步Task工作单元 /// 用于追踪任务调用链的唯一标识符 - /// 是否忽略调试模式下的调用限制检查,true表示强制执行 + /// 是否跳过调用限制检查,true表示跳过 /// 任务执行的超时时间(毫秒),默认为-1,将采用配置时间ActorQueueTimeOut /// 用于取消任务的令牌 /// 返回一个Task对象,表示异步操作的完成状态 @@ -443,9 +488,9 @@ public Task EnqueueAsync(Func work, long callChainId, bool discard = false return Task.FromException(CreateQuarantinedException()); } - if (!discard && GlobalSettings.CurrentSetting.IsDebug && !ActorLimit.AllowCall(Id)) + if (!discard && !ActorLimit.AllowCall(Id)) { - return Task.CompletedTask; + return Task.FromException(CreateCallRejectedException()); } if (timeOut <= 0) @@ -458,6 +503,8 @@ public Task EnqueueAsync(Func work, long callChainId, bool discard = false Owner = this, TimeOut = timeOut, CallChainId = callChainId, + CallerActorId = RuntimeContext.CurrentActor, + LimitRegistered = RuntimeContext.CurrentActor != 0 && RuntimeContext.CurrentActor != Id, }; SendToActionBlock(at, cancellationToken, true); return at.Tcs.Task; @@ -469,7 +516,7 @@ public Task EnqueueAsync(Func work, long callChainId, bool discard = false /// 异步任务返回值的类型 /// 要执行的异步Task工作单元 /// 用于追踪任务调用链的唯一标识符 - /// 是否忽略调试模式下的调用限制检查,true表示强制执行 + /// 是否跳过调用限制检查,true表示跳过 /// 任务执行的超时时间(毫秒),默认为-1,将采用配置时间ActorQueueTimeOut /// 用于取消任务的令牌 /// 返回一个Task对象,表示异步操作的完成状态和结果 @@ -480,9 +527,9 @@ public Task EnqueueAsync(Func> work, long callChainId, bool discar return Task.FromException(CreateQuarantinedException()); } - if (!discard && GlobalSettings.CurrentSetting.IsDebug && !ActorLimit.AllowCall(Id)) + if (!discard && !ActorLimit.AllowCall(Id)) { - return Task.FromResult(default(T)); + return Task.FromException(CreateCallRejectedException()); } if (timeOut <= 0) @@ -495,6 +542,8 @@ public Task EnqueueAsync(Func> work, long callChainId, bool discar Owner = this, TimeOut = timeOut, CallChainId = callChainId, + CallerActorId = RuntimeContext.CurrentActor, + LimitRegistered = RuntimeContext.CurrentActor != 0 && RuntimeContext.CurrentActor != Id, }; SendToActionBlock(at, cancellationToken, true); return at.Tcs.Task; @@ -580,9 +629,9 @@ public Task SendAsync(Action work, int timeOut = -1, CancellationToken cancellat var (needEnqueue, chainId) = IsNeedEnqueue(); if (needEnqueue) { - if (GlobalSettings.CurrentSetting.IsDebug && !ActorLimit.AllowCall(Id)) + if (!ActorLimit.AllowCall(Id)) { - return Task.CompletedTask; + return Task.FromException(CreateCallRejectedException()); } if (timeOut <= 0) @@ -595,6 +644,8 @@ public Task SendAsync(Action work, int timeOut = -1, CancellationToken cancellat Owner = this, TimeOut = timeOut, CallChainId = chainId, + CallerActorId = RuntimeContext.CurrentActor, + LimitRegistered = RuntimeContext.CurrentActor != 0 && RuntimeContext.CurrentActor != Id, }; SendToActionBlock(at, cancellationToken, true); return at.Tcs.Task; @@ -622,9 +673,9 @@ public Task SendAsync(Func work, int timeOut = -1, CancellationToken ca var (needEnqueue, chainId) = IsNeedEnqueue(); if (needEnqueue) { - if (GlobalSettings.CurrentSetting.IsDebug && !ActorLimit.AllowCall(Id)) + if (!ActorLimit.AllowCall(Id)) { - return Task.FromResult(default(T)); + return Task.FromException(CreateCallRejectedException()); } if (timeOut <= 0) @@ -637,6 +688,8 @@ public Task SendAsync(Func work, int timeOut = -1, CancellationToken ca Owner = this, TimeOut = timeOut, CallChainId = chainId, + CallerActorId = RuntimeContext.CurrentActor, + LimitRegistered = RuntimeContext.CurrentActor != 0 && RuntimeContext.CurrentActor != Id, }; SendToActionBlock(at, cancellationToken, true); return at.Tcs.Task; @@ -680,9 +733,9 @@ public Task SendAsync(Func work, int timeOut = -1, bool checkLock = true, var (needEnqueue, chainId) = IsNeedEnqueue(); if (needEnqueue) { - if (checkLock && GlobalSettings.CurrentSetting.IsDebug && !ActorLimit.AllowCall(Id)) + if (checkLock && !ActorLimit.AllowCall(Id)) { - return Task.CompletedTask; + return Task.FromException(CreateCallRejectedException()); } if (timeOut <= 0) @@ -695,6 +748,8 @@ public Task SendAsync(Func work, int timeOut = -1, bool checkLock = true, Owner = this, TimeOut = timeOut, CallChainId = chainId, + CallerActorId = RuntimeContext.CurrentActor, + LimitRegistered = RuntimeContext.CurrentActor != 0 && RuntimeContext.CurrentActor != Id, }; SendToActionBlock(wrapper, cancellationToken, true); return wrapper.Tcs.Task; @@ -721,9 +776,9 @@ public Task SendAsync(Func> work, int timeOut = -1, CancellationTo var (needEnqueue, chainId) = IsNeedEnqueue(); if (needEnqueue) { - if (GlobalSettings.CurrentSetting.IsDebug && !ActorLimit.AllowCall(Id)) + if (!ActorLimit.AllowCall(Id)) { - return Task.FromResult(default(T)); + return Task.FromException(CreateCallRejectedException()); } if (timeOut <= 0) @@ -736,6 +791,8 @@ public Task SendAsync(Func> work, int timeOut = -1, CancellationTo Owner = this, TimeOut = timeOut, CallChainId = chainId, + CallerActorId = RuntimeContext.CurrentActor, + LimitRegistered = RuntimeContext.CurrentActor != 0 && RuntimeContext.CurrentActor != Id, }; SendToActionBlock(wrapper, cancellationToken, true); return wrapper.Tcs.Task; diff --git a/GameFrameX.Core/BaseHandler/GlobalComponentHandler.cs b/GameFrameX.Core/BaseHandler/GlobalComponentHandler.cs index a6779f2d4..62c3dad48 100644 --- a/GameFrameX.Core/BaseHandler/GlobalComponentHandler.cs +++ b/GameFrameX.Core/BaseHandler/GlobalComponentHandler.cs @@ -29,6 +29,7 @@ using GameFrameX.Core.Abstractions.Agent; using GameFrameX.Core.BaseHandler.Normal; +using GameFrameX.Core.Hotfix; using GameFrameX.Core.Components; using GameFrameX.Network.Abstractions; using GameFrameX.Utility; @@ -52,7 +53,7 @@ protected override Task InitActorAsync() { if (ActorId <= 0) { - var compType = ComponentAgentType.BaseType.GetGenericArguments()[0]; + var compType = HotfixModule.GetComponentTypeFromAgent(ComponentAgentType); var actorType = ComponentRegister.GetActorType(compType); ActorId = ActorIdGenerator.GetActorId(actorType); } diff --git a/GameFrameX.Core/BaseHandler/GlobalRpcComponentHandler.cs b/GameFrameX.Core/BaseHandler/GlobalRpcComponentHandler.cs index c05fe64b0..2f6f8bedf 100644 --- a/GameFrameX.Core/BaseHandler/GlobalRpcComponentHandler.cs +++ b/GameFrameX.Core/BaseHandler/GlobalRpcComponentHandler.cs @@ -29,6 +29,7 @@ using GameFrameX.Core.Abstractions.Agent; using GameFrameX.Core.BaseHandler.RPC; +using GameFrameX.Core.Hotfix; using GameFrameX.Core.Components; using GameFrameX.Network.Abstractions; using GameFrameX.Utility; @@ -56,7 +57,7 @@ protected override Task InitActorAsync() { if (ActorId <= 0) { - var compType = ComponentAgentType.BaseType.GetGenericArguments()[0]; + var compType = HotfixModule.GetComponentTypeFromAgent(ComponentAgentType); var actorType = ComponentRegister.GetActorType(compType); ActorId = ActorIdGenerator.GetActorId(actorType); } diff --git a/GameFrameX.Core/BaseHandler/PlayerComponentHandler.cs b/GameFrameX.Core/BaseHandler/PlayerComponentHandler.cs index be3778a71..495f150b9 100644 --- a/GameFrameX.Core/BaseHandler/PlayerComponentHandler.cs +++ b/GameFrameX.Core/BaseHandler/PlayerComponentHandler.cs @@ -30,6 +30,9 @@ using GameFrameX.Core.Abstractions.Agent; using GameFrameX.Core.BaseHandler.Normal; using GameFrameX.Network.Abstractions; +using GameFrameX.Foundation.Localization.Core; +using GameFrameX.Foundation.Logger; + using GameFrameX.Utility; using GameFrameX.Utility.Setting; @@ -58,7 +61,11 @@ protected override Task InitActorAsync() if (ActorId <= 0) { - ActorId = ActorIdGenerator.GetActorId(GlobalConst.ActorTypePlayer); + // Localization: Core.MessageHandler.ActorIdIsZero - ActorId为0,无法获取组件,{0},关闭通道 + // 未绑定玩家身份的连接不再触发随机 ActorId 回退:每次请求制造新 Actor 且默认状态落库会形成垃圾玩家记录 + LogHelper.Fatal(LocalizationService.GetString(Localization.Keys.Core.MessageHandler.ActorIdIsZero, Message?.GetType().FullName)); + NetworkChannel.Close(); + return Task.FromResult(false); } return Task.FromResult(true); diff --git a/GameFrameX.Core/BaseHandler/PlayerRpcComponentHandler.cs b/GameFrameX.Core/BaseHandler/PlayerRpcComponentHandler.cs index 8a0255e96..0794cb4ab 100644 --- a/GameFrameX.Core/BaseHandler/PlayerRpcComponentHandler.cs +++ b/GameFrameX.Core/BaseHandler/PlayerRpcComponentHandler.cs @@ -30,6 +30,9 @@ using GameFrameX.Core.Abstractions.Agent; using GameFrameX.Core.BaseHandler.RPC; using GameFrameX.Network.Abstractions; +using GameFrameX.Foundation.Localization.Core; +using GameFrameX.Foundation.Logger; + using GameFrameX.Utility; using GameFrameX.Utility.Setting; @@ -61,7 +64,11 @@ protected override Task InitActorAsync() if (ActorId <= 0) { - ActorId = ActorIdGenerator.GetActorId(GlobalConst.ActorTypePlayer); + // Localization: Core.MessageHandler.ActorIdIsZero - ActorId为0,无法获取组件,{0},关闭通道 + // 未绑定玩家身份的连接不再触发随机 ActorId 回退:每次请求制造新 Actor 且默认状态落库会形成垃圾玩家记录 + LogHelper.Fatal(LocalizationService.GetString(Localization.Keys.Core.MessageHandler.ActorIdIsZero, RequestMessage?.GetType().FullName)); + NetworkChannel.Close(); + return Task.FromResult(false); } return Task.FromResult(true); diff --git a/GameFrameX.Core/BaseHandler/RPC/BaseRpcComponentHandler.cs b/GameFrameX.Core/BaseHandler/RPC/BaseRpcComponentHandler.cs index a978efb5c..67303557a 100644 --- a/GameFrameX.Core/BaseHandler/RPC/BaseRpcComponentHandler.cs +++ b/GameFrameX.Core/BaseHandler/RPC/BaseRpcComponentHandler.cs @@ -131,25 +131,61 @@ public override Task InnerActionAsync(int timeout = 30000, CancellationToken can return Task.CompletedTask; } - CacheComponent.Tell(() => ExecuteWorkAsync(cancellationToken), timeout, cancellationToken); + CacheComponent.Tell(() => ExecuteWorkAsync(timeout, cancellationToken), timeout, cancellationToken); return Task.CompletedTask; } /// - /// 在组件代理上执行内部处理并回写响应(由 调度) + /// 在组件代理上执行内部处理并回写响应(由 调度)。 + /// 业务核心以 包装:超时时向客户端回写独立的 + /// 响应(含 requestId),不再让客户端只能等自身超时。 /// + /// 执行超时时间,单位毫秒 /// 取消令牌 - private async Task ExecuteWorkAsync(CancellationToken cancellationToken) + private async Task ExecuteWorkAsync(int timeout, CancellationToken cancellationToken) { var response = MessageObjectPoolHelper.Get(); var requestId = RequestMessage.UniqueId; + Task coreTask = null; try { try { - await InnerActionCoreAsync(RequestMessage, response, cancellationToken); + coreTask = InnerActionCoreAsync(RequestMessage, response, cancellationToken); + await coreTask.WaitAsync(TimeSpan.FromMilliseconds(timeout), cancellationToken); response?.SetUniqueId(requestId); } + catch (TimeoutException) + { + // Localization: Core.MessageHandler.RpcComponentExecutionTimeout - BaseRpcComponentHandler组件RPC执行超时,消息类型:{0} 超时:{1}ms + LogHelper.Error(LocalizationService.GetString(Localization.Keys.Core.MessageHandler.RpcComponentExecutionTimeout, RequestMessage.GetType().FullName, timeout)); + + // 超时响应使用独立池化实例:孤儿业务任务仍持有原响应,其写入不得污染已回写的超时响应 + var timeoutResponse = MessageObjectPoolHelper.Get(); + try + { + timeoutResponse.ErrorCode = OperationErrorCode.TimeOut; + timeoutResponse.SetUniqueId(requestId); + if (NetworkChannel != null && !NetworkChannel.IsClosed()) + { + await NetworkChannel.WriteAsync(timeoutResponse); + } + } + finally + { + MessageObjectPoolHelper.Return(timeoutResponse); + } + + // 原响应经终态延续归还,此处不再写回 + return; + } + catch (RpcException e) + { + if (response != null) + { + response.ErrorCode = e.ErrorCode; + } + } catch (Exception e) { // Localization: Core.MessageHandler.InnerActionError - 内部操作错误: {0} {1} @@ -160,6 +196,13 @@ private async Task ExecuteWorkAsync(CancellationToken cancellationToken) } } + // 取消等路径下业务核心仍在运行(孤儿持有 response 引用):写共享实例会被并发改写撕裂, + // 该响应无人等待时不写回(超时路径已用独立实例回写过 TimeOut) + if (coreTask != null && !coreTask.IsCompleted) + { + return; + } + if (NetworkChannel == null || NetworkChannel.IsClosed() || response == null) { return; @@ -169,10 +212,27 @@ private async Task ExecuteWorkAsync(CancellationToken cancellationToken) } finally { - // 任意路径(含通道关闭早退、写通道异常)均归还响应对象,避免对象池耗尽 - if (response != null) + // 任意路径(含通道关闭早退、写通道异常)均归还响应对象,避免对象池耗尽; + // 超时孤儿业务任务未到终态时,经终态延续归还(对齐 BaseRpcMessageHandler 模式) + if (coreTask == null || coreTask.IsCompleted) { - MessageObjectPoolHelper.Return(response); + if (response != null) + { + MessageObjectPoolHelper.Return(response); + } + } + else + { + _ = coreTask.ContinueWith((completedTask, state) => + { + _ = completedTask.Exception; + var orphanResponse = (TResponse)state; + if (orphanResponse != null) + { + MessageObjectPoolHelper.Return(orphanResponse); + } + }, response, CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); } } } diff --git a/GameFrameX.Core/Components/ComponentRegister.cs b/GameFrameX.Core/Components/ComponentRegister.cs index becec5b3a..20b48e15d 100644 --- a/GameFrameX.Core/Components/ComponentRegister.cs +++ b/GameFrameX.Core/Components/ComponentRegister.cs @@ -66,6 +66,13 @@ public static class ComponentRegister /// private static readonly Dictionary ComponentFuncDic = new(); + /// + /// 初始化锁:全表扫描与静态字典写入的串行化点。 + /// 多测试类/多入口并行初始化时保护静态字典不被并发写损坏(注册语义不变)。 + /// + private static readonly object InitLock = new(); + + /// /// 根据 CompType 获取对应的 ActorType 类型 /// @@ -104,35 +111,39 @@ public static Task InitAsync(Assembly assembly = null) ArgumentNullException.ThrowIfNull(assembly); var baseCompName = typeof(BaseComponent); var types = assembly.GetTypes(); - foreach (var type in types) + // 全表扫描与四个静态字典的写入收敛进单一锁:并行初始化(多测试类/多入口)不再损坏字典 + lock (InitLock) { - if (type.IsAbstract || !type.IsSubclassOf(baseCompName)) + foreach (var type in types) { - continue; - } + if (type.IsAbstract || !type.IsSubclassOf(baseCompName)) + { + continue; + } - if (type.GetCustomAttribute(typeof(ComponentTypeAttribute)) is ComponentTypeAttribute compAttr) - { - var actorType = compAttr.Type; - var compTypes = ActorComponentDic.GetOrAddValue(actorType); - compTypes.Add(type); + if (type.GetCustomAttribute(typeof(ComponentTypeAttribute)) is ComponentTypeAttribute compAttr) + { + var actorType = compAttr.Type; + var compTypes = ActorComponentDic.GetOrAddValue(actorType); + compTypes.Add(type); - ComponentActorDic[type] = actorType; + ComponentActorDic[type] = actorType; - if (actorType < GlobalConst.ActorTypeSeparator) - { - if (type.GetCustomAttribute(typeof(FuncAttribute)) is FuncAttribute funcAttr) + if (actorType < GlobalConst.ActorTypeSeparator) { - var set = FuncComponentDic.GetOrAddValue(funcAttr.Func); - set.Add(type); - ComponentFuncDic[type] = funcAttr.Func; + if (type.GetCustomAttribute(typeof(FuncAttribute)) is FuncAttribute funcAttr) + { + var set = FuncComponentDic.GetOrAddValue(funcAttr.Func); + set.Add(type); + ComponentFuncDic[type] = funcAttr.Func; + } } } - } - else - { - // Localization: CoreExceptions.Component.ActorTypeNotBound - 组件:[{0}] Actor类型未绑定 - throw new Exception(LocalizationService.GetString(Localization.Keys.CoreExceptions.Component.ActorTypeNotBound, type.FullName)); + else + { + // Localization: CoreExceptions.Component.ActorTypeNotBound - 组件:[{0}] Actor类型未绑定 + throw new Exception(LocalizationService.GetString(Localization.Keys.CoreExceptions.Component.ActorTypeNotBound, type.FullName)); + } } } diff --git a/GameFrameX.Core/Hotfix/Agent/BaseComponentAgent.QuartzTimer.cs b/GameFrameX.Core/Hotfix/Agent/BaseComponentAgent.QuartzTimer.cs index ebfcb3a39..9da3e39fa 100644 --- a/GameFrameX.Core/Hotfix/Agent/BaseComponentAgent.QuartzTimer.cs +++ b/GameFrameX.Core/Hotfix/Agent/BaseComponentAgent.QuartzTimer.cs @@ -32,6 +32,8 @@ using GameFrameX.Core.Timer; using GameFrameX.Core.Timer.Handler; +using GameFrameX.Foundation.Utility; + namespace GameFrameX.Core.Hotfix.Agent; /// @@ -86,9 +88,9 @@ public async Task DelayAsync(DateTime time, GameEventArgs eventArgs = n } /// - /// 延迟执行定时任务,将时间戳转换为DateTime后在指定时间点执行一次任务 + /// 延迟执行定时任务,将 Unix 毫秒时间戳转换为DateTime后在指定时间点执行一次任务 /// - /// Unix时间戳,将被转换为DateTime类型的执行时间点 + /// Unix 毫秒时间戳(经 TimestampMillisecondsToDateTime 转换为执行时间点,不是 Ticks) /// 传递给定时任务处理器的自定义参数对象 /// 需要取消的已存在的定时任务ID,如果大于0则会先取消该任务 /// 实现了ITimerHandler接口的定时任务处理器类型 @@ -96,7 +98,7 @@ public async Task DelayAsync(DateTime time, GameEventArgs eventArgs = n public async Task DelayAsync(long time, GameEventArgs eventArgs = null, long unScheduleId = 0) where T : ITimerHandler { await UnscheduledAsync(unScheduleId); - var scheduleId = await QuartzTimer.DelayAsync(ActorId, new DateTime(time) - DateTime.Now, eventArgs); + var scheduleId = await QuartzTimer.DelayAsync(ActorId, TimerHelper.TimestampMillisecondsToDateTime(time, utc: true) - DateTime.UtcNow, eventArgs); ScheduleIdSet.Add(scheduleId); return scheduleId; } diff --git a/GameFrameX.Core/Hotfix/HotfixLoadOptions.cs b/GameFrameX.Core/Hotfix/HotfixLoadOptions.cs index 3717709ac..555837a41 100644 --- a/GameFrameX.Core/Hotfix/HotfixLoadOptions.cs +++ b/GameFrameX.Core/Hotfix/HotfixLoadOptions.cs @@ -79,6 +79,19 @@ public sealed class HotfixLoadOptions /// public string AssemblyFileName { get; init; } = "GameFrameX.Hotfix.dll"; + /// + /// 期望的热更程序集 SHA-256(十六进制,默认空 = 不校验)。 + /// The expected SHA-256 of the hotfix assembly (hexadecimal; empty by default = no verification). + /// + /// + /// 仅当 AppSetting.HotfixVerifyEnabled 灰度开关开启且本值非空时,HotfixModule.Init + /// 才在创建可收集上下文与装载前流式校验文件内容,不匹配即拒绝装载。 + /// Only when the AppSetting.HotfixVerifyEnabled gray switch is on and this value is non-empty + /// does HotfixModule.Init stream-verify the file content before creating the collectible + /// context and loading; a mismatch rejects the load. + /// + public string ExpectedSha256 { get; init; } = string.Empty; + /// /// 版本值禁止出现的路径相关字符(分隔符与卷分隔符)。 /// Path-relevant characters (separators and the volume separator) forbidden in version values. diff --git a/GameFrameX.Core/Hotfix/HotfixManager.cs b/GameFrameX.Core/Hotfix/HotfixManager.cs index a8d2520b9..ff40bf921 100644 --- a/GameFrameX.Core/Hotfix/HotfixManager.cs +++ b/GameFrameX.Core/Hotfix/HotfixManager.cs @@ -146,7 +146,7 @@ public static bool LoadHotfix(AppSetting setting, HotfixLoadOptions options = nu // 装载前幂等确保目录存在(历史由调用方各自兜底创建,收敛为装载入口自身的前置条件) EnsureHotfixDirectory(path); - var hotfixModule = new HotfixModule(path); + var hotfixModule = new HotfixModule(path, loadOptions); var reload = Host.CurrentModule != null; // 起服时失败会有异常抛出 var success = hotfixModule.Init(reload); @@ -196,7 +196,7 @@ public static async Task LoadHotfixModuleAsync(AppSetting setting, HotfixL // 装载前幂等确保目录存在(历史由调用方各自兜底创建,收敛为装载入口自身的前置条件) EnsureHotfixDirectory(path); - var hotfixModule = new HotfixModule(path); + var hotfixModule = new HotfixModule(path, loadOptions); var reload = Host.CurrentModule != null; // 起服时失败会有异常抛出 var success = hotfixModule.Init(reload); diff --git a/GameFrameX.Core/Hotfix/HotfixModule.cs b/GameFrameX.Core/Hotfix/HotfixModule.cs index 5df6182fb..46c4c881b 100644 --- a/GameFrameX.Core/Hotfix/HotfixModule.cs +++ b/GameFrameX.Core/Hotfix/HotfixModule.cs @@ -29,6 +29,8 @@ using System.Collections.Concurrent; using System.Reflection; +using System.Security.Cryptography; +using System.Runtime.Loader; using GameFrameX.Core.Abstractions.Agent; using GameFrameX.Core.Abstractions.Events; using GameFrameX.Core.BaseHandler; @@ -81,13 +83,12 @@ internal sealed class HotfixModule : ICollectibleAssemblyModule /// /// HTTP命令到处理器的映射。 /// - private readonly ConcurrentDictionary _httpHandlerMap = new ConcurrentDictionary(); + private readonly ConcurrentDictionary _httpHandlerMap = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); /// - /// RPC请求类型到响应类型的映射。 + /// RPC请求类型到处理器实例工厂的映射(注册期经 CreateFactory 校验并构建;处理器有状态,工厂每次调用创建新实例)。 /// - private readonly ConcurrentDictionary _rpcHandlerMap = new ConcurrentDictionary(); - + private readonly ConcurrentDictionary> _rpcHandlerMap = new ConcurrentDictionary>(); /// /// 消息ID到处理器实例工厂的映射(注册期构建;处理器有状态,工厂每次调用创建新实例)。 /// @@ -113,6 +114,11 @@ internal sealed class HotfixModule : ICollectibleAssemblyModule /// The collectible load context (module lifecycle and unload verification are driven by the module chain via the contract). /// private CollectibleAssemblyLoadContext _loadContext; + /// + /// 热更装载参数(磁盘装载构造用;承载装载哈希期望值等校验输入)。 + /// + private readonly HotfixLoadOptions _loadOptions; + /// /// 热更程序集。 @@ -133,9 +139,11 @@ Assembly ICollectibleAssemblyModule.LoadedAssembly /// 构造函数,接受DLL路径。 /// /// DLL路径。 - internal HotfixModule(string dllPath) + /// 装载参数(null 时无哈希期望,行为与无参装载一致)。 + internal HotfixModule(string dllPath, HotfixLoadOptions loadOptions = null) { _dllPath = dllPath; + _loadOptions = loadOptions; } /// @@ -185,13 +193,15 @@ internal bool Init(bool reload) var success = false; try { + // 装载信任防线:开关开启且配置了期望哈希时,在创建可收集上下文与装载前完成内容完整性校验 + //(校验失败时起服路径异常上抛、reload 路径经 catch 返回 false,且不产生任何 ALC/装载副作用) + VerifyAssemblyHash(); + _loadContext = new CollectibleAssemblyLoadContext(); HotfixAssembly = _loadContext.LoadFromAssemblyPath(_dllPath); - if (!reload) - { - // 启动服务器时加载关联的DLL - LoadRefAssemblies(); - } + // 依赖装载进当前模块的可收集上下文,随模块退役回收;热重载路径同样需要 + //(新上下文内没有副本,若仅启动时装载,重载后的热更程序集在首次使用依赖类型时解析失败) + LoadRefAssemblies(); ConvertHotfixAssembly(); @@ -237,8 +247,12 @@ public WeakReference Unload() /// private void LoadRefAssemblies() { - var assemblies = AppDomain.CurrentDomain.GetAssemblies(); - var nameSet = new HashSet(assemblies.Select(t => t.GetName().Name)); + // 判重基准只取默认加载上下文内已存在的程序集名:可收集上下文(历史模块)内的同名副本 + // 不参与判重——热重载的新模块需要自己上下文内的副本,否则依赖类型在新模块内解析失败 + var defaultLoadContext = AssemblyLoadContext.Default; + var nameSet = new HashSet(AppDomain.CurrentDomain.GetAssemblies() + .Where(t => AssemblyLoadContext.GetLoadContext(t) == defaultLoadContext) + .Select(t => t.GetName().Name)); var hotfixRefAssemblies = HotfixAssembly.GetReferencedAssemblies(); foreach (var refAssembly in hotfixRefAssemblies) { @@ -250,11 +264,40 @@ private void LoadRefAssemblies() var refPath = $"{Environment.CurrentDirectory}/{refAssembly.Name}.dll"; if (File.Exists(refPath)) { - Assembly.LoadFrom(refPath); + // 依赖与主模块同生共死:经可收集上下文装载,随模块卸载回收,不再进入默认 ALC 永不卸载 + _loadContext.LoadFromAssemblyPath(refPath); } } } + /// + /// 装载前校验热更程序集内容完整性:灰度开关开启且配置了期望 SHA-256 时流式计算文件哈希,不匹配即抛出。 + /// + /// + /// 开关关闭(默认)或未配置期望哈希时不做任何事,行为与历史逐字节一致。 + /// + private void VerifyAssemblyHash() + { + var expectedSha256 = _loadOptions?.ExpectedSha256; + if (string.IsNullOrEmpty(expectedSha256) || !(GlobalSettings.CurrentSetting?.HotfixVerifyEnabled ?? false)) + { + return; + } + + string actualSha256; + using (var fileStream = File.OpenRead(_dllPath)) + using (var sha256 = SHA256.Create()) + { + actualSha256 = Convert.ToHexString(sha256.ComputeHash(fileStream)); + } + + if (!string.Equals(expectedSha256, actualSha256, StringComparison.OrdinalIgnoreCase)) + { + // Localization: CoreExceptions.HotfixModule.AssemblyHashMismatch - 热更程序集哈希校验失败,路径:{0} 期望SHA-256:{1} 实际SHA-256:{2} + throw new InvalidOperationException(LocalizationService.GetString(Localization.Keys.CoreExceptions.HotfixModule.AssemblyHashMismatch, _dllPath, expectedSha256, actualSha256)); + } + } + /// /// 解析DLL中的类型并进行注册。 /// @@ -364,11 +407,17 @@ private bool AddHttpHandler(Type type) throw new Exception(LocalizationService.GetString(Localization.Keys.CoreExceptions.HotfixModule.HttpProcessorRepeatedlyRegistered, attr.OriginalCmd)); } + // 注册标准化的命名:与原始命令仅大小写等价(全小写类名)时同一命令,跳过二次注册 + if (string.Equals(attr.OriginalCmd, attr.StandardCmd, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + // 注册标准化的命名 if (!_httpHandlerMap.TryAdd(attr.StandardCmd, handler)) { // Localization: CoreExceptions.Hotfix.HttpProcessorRepeatedlyRegistered - HTTP处理器命令重复注册,命令:{0} - throw new Exception(LocalizationService.GetString(Localization.Keys.CoreExceptions.HotfixModule.HttpProcessorRepeatedlyRegistered, attr.OriginalCmd)); + throw new Exception(LocalizationService.GetString(Localization.Keys.CoreExceptions.HotfixModule.HttpProcessorRepeatedlyRegistered, attr.StandardCmd)); } return true; @@ -382,23 +431,16 @@ private bool AddHttpHandler(Type type) /// 对应的 实例,如果未找到则返回 null。 internal IMessageHandler GetRpcHandler(Type type) { - if (!_rpcHandlerMap.TryGetValue(type, out var handlerType)) + if (!_rpcHandlerMap.TryGetValue(type, out var handlerFactory)) { return default; } - var instance = Activator.CreateInstance(handlerType); - if (instance is IMessageHandler handler) - { - return handler; - } - - // Localization: CoreExceptions.Hotfix.WrongMessageHandlerType - 错误的消息处理器类型:{0} - throw new Exception(LocalizationService.GetString(Localization.Keys.CoreExceptions.HotfixModule.WrongMessageHandlerType, instance.GetType().FullName)); + return handlerFactory(); } /// - /// 添加RPC处理器。 + /// 添加RPC处理器:注册期经 校验可实例化契约并构建工厂,同一请求消息重复注册 fail-fast。 /// /// 处理器类型。 /// 是否添加成功。 @@ -410,16 +452,13 @@ private bool AddRpcHandler(Type type) return false; } - var isHas = _rpcHandlerMap.TryGetValue(attribute.RequestMessage.GetType(), out var requestHandler); - if (isHas && requestHandler?.GetType() == attribute.ResponseMessage.GetType()) + var handlerFactory = CreateFactory(type); + if (!_rpcHandlerMap.TryAdd(attribute.RequestMessageType, handlerFactory)) { - // Localization: CoreExceptions.Hotfix.HttpProcessorRepeatedlyRegistered - HTTP处理器命令重复注册,命令:{0} - LogHelper.Error(LocalizationService.GetString(Localization.Keys.CoreExceptions.HotfixModule.HttpProcessorRepeatedlyRegistered, attribute.RequestMessage)); - return false; + // Localization: CoreExceptions.HotfixModule.RpcHandlerRepeatedlyRegistered - RPC处理器重复注册,请求消息类型:{0} 处理器类型:{1} + throw new InvalidOperationException(LocalizationService.GetString(Localization.Keys.CoreExceptions.HotfixModule.RpcHandlerRepeatedlyRegistered, attribute.RequestMessageType.FullName, type.FullName)); } - _rpcHandlerMap.TryAdd(attribute.RequestMessage.GetType(), attribute.ResponseMessage.GetType()); - return true; } @@ -471,8 +510,8 @@ private bool AddMessageHandler(Type type) var messageHandlerFactory = CreateFactory(type); if (!_messageHandlerMap.TryAdd(msgId, messageHandlerFactory)) { - // Localization: CoreExceptions.Hotfix.WrongMessageHandlerType - 错误的消息处理器类型:{0} - LogHelper.Error(LocalizationService.GetString(Localization.Keys.CoreExceptions.HotfixModule.WrongMessageHandlerType, type)); + // Localization: CoreExceptions.HotfixModule.MessageIdRepeatedlyRegistered - 消息号重复注册,消息号:{0} 处理器类型:{1} + throw new InvalidOperationException(LocalizationService.GetString(Localization.Keys.CoreExceptions.HotfixModule.MessageIdRepeatedlyRegistered, msgId, type.FullName)); } _messageHandlerTypes.Add(attribute.MessageType); @@ -509,9 +548,14 @@ private bool AddEvent(Type type) throw new Exception(LocalizationService.GetString(Localization.Keys.CoreExceptions.HotfixModule.EventHandlerWrongSuffix, GlobalConst.EventListenerNameSuffix, classFullName)); } - var compAgentType = type.BaseType.GetGenericArguments()[0]; - var compType = compAgentType.BaseType.GetGenericArguments()[0]; - var actorType = ComponentRegister.ComponentActorDic[compType]; + var compAgentType = GetComponentTypeFromAgent(type); + var compType = GetComponentTypeFromAgent(compAgentType); + if (!ComponentRegister.ComponentActorDic.TryGetValue(compType, out var actorType)) + { + // Localization: CoreExceptions.Component.ActorTypeNotBound - 组件:[{0}] Actor类型未绑定 + throw new InvalidOperationException(LocalizationService.GetString(Localization.Keys.CoreExceptions.Component.ActorTypeNotBound, compType.FullName)); + } + var evtListenersDic = _actorEvtListeners.GetOrAddValue(actorType); var eventAttribute = type.GetCustomAttribute(); @@ -566,7 +610,7 @@ private bool AddAgent(Type type) throw new Exception(LocalizationService.GetString(Localization.Keys.CoreExceptions.HotfixModule.ComponentAgentWrongSuffix, GlobalConst.ComponentAgentNameSuffix, fullName)); } - var compType = type.BaseType.GetGenericArguments()[0]; + var compType = GetComponentTypeFromAgent(type); if (!_compAgentMap.TryAdd(compType, type)) { // Localization: CoreExceptions.Hotfix.MultipleAgents - 组件:[{0}] 存在多个代理 @@ -577,6 +621,24 @@ private bool AddAgent(Type type) return true; } + /// + /// 从代理/监听器类型反推其组件类型:读取基类泛型参数并做契约守卫。 + /// + /// 代理或监听器类型。 + /// 基类泛型参数指定的组件(或代理)类型。 + /// 基类为空、非泛型或未声明泛型参数(契约违规类型)。 + internal static Type GetComponentTypeFromAgent(Type agentType) + { + var baseType = agentType.BaseType; + if (baseType == null || !baseType.IsGenericType || baseType.GetGenericArguments().Length == 0) + { + // Localization: CoreExceptions.HotfixModule.AgentBaseTypeInvalid - 组件代理基类型无效,无法推导组件类型: {0} + throw new InvalidOperationException(LocalizationService.GetString(Localization.Keys.CoreExceptions.HotfixModule.AgentBaseTypeInvalid, agentType.FullName)); + } + + return baseType.GetGenericArguments()[0]; + } + /// /// 获取消息处理器。 diff --git a/GameFrameX.Launcher/StartUp/AppStartUpGameServerBase.cs b/GameFrameX.Launcher/StartUp/AppStartUpGameServerBase.cs index beb535d34..896ad9c1a 100644 --- a/GameFrameX.Launcher/StartUp/AppStartUpGameServerBase.cs +++ b/GameFrameX.Launcher/StartUp/AppStartUpGameServerBase.cs @@ -94,7 +94,15 @@ public sealed override async Task StartAsync() await PrepareAsync(); LogHelper.Debug(LocalizationService.GetString(Localization.Keys.Launcher.ActorLimitConfigBegin)); - ActorLimit.Init(ActorLimit.RuleType.None); + // 规则改由配置驱动:一经配置即生效(生产可用),默认 None 行为与历史等价 + if (!Enum.TryParse(Setting.ActorLimitRule, true, out var actorLimitRule)) + { + // Localization: Core.ActorLimit.UnsupportedRuleType - 不支持的rule类型:{0} + LogHelper.Error(LocalizationService.GetString(Localization.Keys.Core.ActorLimit.UnsupportedRuleType, Setting.ActorLimitRule)); + actorLimitRule = ActorLimit.RuleType.None; + } + + ActorLimit.Init(actorLimitRule); LogHelper.Debug(LocalizationService.GetString(Localization.Keys.Launcher.ActorLimitConfigEnd)); LogHelper.Debug(LocalizationService.GetString(Localization.Keys.Launcher.DatabaseServiceStartBegin)); diff --git a/GameFrameX.Localization/Keys.Core.cs b/GameFrameX.Localization/Keys.Core.cs index 619df0fc7..ef759870a 100644 --- a/GameFrameX.Localization/Keys.Core.cs +++ b/GameFrameX.Localization/Keys.Core.cs @@ -664,6 +664,16 @@ public static class MessageHandler /// public const string RpcGetComponentFailed = "Core.MessageHandler.RpcGetComponentFailed"; + /// + /// BaseRpcComponentHandler 组件RPC处理器执行超时,消息类型:{0} 超时:{1}ms + /// + /// + /// 键名: Core.MessageHandler.RpcComponentExecutionTimeout + /// 用途: 组件RPC处理器业务核心超时时记录并回写超时响应 + /// 参数: {0} - 请求消息类型, {1} - 超时时间(毫秒) + /// + public const string RpcComponentExecutionTimeout = "Core.MessageHandler.RpcComponentExecutionTimeout"; + /// /// BaseComponentHandler.InnerActionAsync 缓存组件为空,消息类型:{0} /// @@ -917,6 +927,14 @@ public static class WorkerActor public const string TraceUnavailable = "CoreExceptions.WorkerActor.TraceUnavailable"; /// + /// WorkerActor work call rejected by limit rule, caller actorId:{0} target actorId:{1} + /// + /// + /// 键名: CoreExceptions.WorkerActor.CallRejectedByLimitRule + /// 用途: 工作单元被Actor调用限制规则拒绝时以异常返回给等待方 + /// 参数: {0} - 调用方Actor ID, {1} - 目标Actor ID + /// + public const string CallRejectedByLimitRule = "CoreExceptions.WorkerActor.CallRejectedByLimitRule"; /// WorkerActor 队列拒绝了工作单元。actorId:{0} trace:{1} /// /// @@ -1157,6 +1175,44 @@ public static class HotfixModule public const string MissingParameterlessConstructor = "CoreExceptions.Hotfix.MissingParameterlessConstructor"; /// + /// Component agent base type is invalid, can not resolve the component type: {0} + /// + /// + /// 键名: CoreExceptions.HotfixModule.AgentBaseTypeInvalid + /// 用途: 从代理/监听器类型反推组件类型时基类为空/非泛型/无泛型参数抛出异常 + /// 参数: {0} - 代理类型全名 + /// + public const string AgentBaseTypeInvalid = "CoreExceptions.HotfixModule.AgentBaseTypeInvalid"; + + /// + /// RPC handler repeatedly registers, request message type:{0} handler type:{1} + /// + /// + /// 键名: CoreExceptions.HotfixModule.RpcHandlerRepeatedlyRegistered + /// 用途: 同一请求消息类型重复注册RPC处理器时抛出异常 + /// 参数: {0} - 请求消息类型全名, {1} - 处理器类型全名 + /// + public const string RpcHandlerRepeatedlyRegistered = "CoreExceptions.HotfixModule.RpcHandlerRepeatedlyRegistered"; + + /// + /// Message id repeatedly registers, message id:{0} handler type:{1} + /// + /// + /// 键名: CoreExceptions.HotfixModule.MessageIdRepeatedlyRegistered + /// 用途: 同一消息号重复注册消息处理器时抛出异常 + /// 参数: {0} - 消息号, {1} - 处理器类型全名 + /// + public const string MessageIdRepeatedlyRegistered = "CoreExceptions.HotfixModule.MessageIdRepeatedlyRegistered"; + + /// + /// Hotfix assembly hash mismatch, path:{0} expected SHA-256:{1} actual SHA-256:{2} + /// + /// + /// 键名: CoreExceptions.HotfixModule.AssemblyHashMismatch + /// 用途: 热更程序集装载前SHA-256哈希校验失败时抛出异常 + /// 参数: {0} - 程序集路径, {1} - 期望SHA-256, {2} - 实际SHA-256 + /// + public const string AssemblyHashMismatch = "CoreExceptions.HotfixModule.AssemblyHashMismatch"; /// Version 含非法路径字符:{0} /// /// diff --git a/GameFrameX.Localization/Localization/Messages/Resources.resx b/GameFrameX.Localization/Localization/Messages/Resources.resx index f833d4134..f7a878775 100644 --- a/GameFrameX.Localization/Localization/Messages/Resources.resx +++ b/GameFrameX.Localization/Localization/Messages/Resources.resx @@ -554,6 +554,9 @@ trace unavailable: {0} + + WorkerActor work call rejected by limit rule, caller actorId:{0} target actorId:{1} + WorkerActor queue rejected work. actorId:{0} trace:{1} @@ -565,7 +568,6 @@ WorkerActor queue post failed. actorId:{0} trace:{1} - component:[{0}] the actor type is not bound @@ -621,6 +623,18 @@ {0} must be a non-abstract closed type implementing {1} with a public parameterless constructor + + Component agent base type is invalid, can not resolve the component type: {0} + + + RPC handler repeatedly registers, request message type:{0} handler type:{1} + + + Message id repeatedly registers, message id:{0} handler type:{1} + + + Hotfix assembly hash mismatch, path:{0} expected SHA-256:{1} actual SHA-256:{2} + Version contains forbidden path characters: {0} @@ -628,7 +642,6 @@ Type not found: {0} - Invalid component type {0}, value should be greater than {1} and less than {2} and not equal to {3} @@ -1821,6 +1834,9 @@ BaseRpcComponentHandler.InitAsync, get component failed, close channel, actorId: {0}, componentAgentType: {1}, exception:\n{2} + + BaseRpcComponentHandler component rpc execution timeout, request type:{0} timeout:{1}ms + BaseRpcComponentHandler.InnerActionAsync, CacheComponent is null, message type: {0} diff --git a/GameFrameX.Localization/Localization/Messages/Resources.zh-CN.resx b/GameFrameX.Localization/Localization/Messages/Resources.zh-CN.resx index 9164c7917..d06b145de 100644 --- a/GameFrameX.Localization/Localization/Messages/Resources.zh-CN.resx +++ b/GameFrameX.Localization/Localization/Messages/Resources.zh-CN.resx @@ -572,6 +572,9 @@ trace不可用:{0} + + WorkerActor工作调用被限流规则拒绝,调用方ActorId:{0} 目标ActorId:{1} + WorkerActor 队列拒绝了工作单元。actorId:{0} trace:{1} @@ -583,7 +586,6 @@ WorkerActor 队列投递失败。actorId:{0} trace:{1} - 组件:[{0}] Actor类型未绑定 @@ -642,6 +644,18 @@ {0} 必须是实现 {1} 的非抽象封闭类型,且具有公共无参构造函数 + + 组件代理基类型无效,无法推导组件类型: {0} + + + RPC处理器重复注册,请求消息类型:{0} 处理器类型:{1} + + + 消息号重复注册,消息号:{0} 处理器类型:{1} + + + 热更程序集哈希校验失败,路径:{0} 期望SHA-256:{1} 实际SHA-256:{2} + Version 含非法路径字符:{0} @@ -649,7 +663,6 @@ 未找到类型:{0} - 无效的组件类型 {0},值应大于{1}且小于{2}和不为{3} @@ -1815,6 +1828,9 @@ BaseRpcComponentHandler.InitAsync 获取组件失败,关闭通道,actorId:{0},组件代理类型:{1},异常:\n{2} + + BaseRpcComponentHandler组件RPC执行超时,消息类型:{0} 超时:{1}ms + BaseRpcComponentHandler.InnerActionAsync 缓存组件为空,消息类型:{0} diff --git a/GameFrameX.Network.Abstractions/MessageRpcMappingAttribute.cs b/GameFrameX.Network.Abstractions/MessageRpcMappingAttribute.cs index fd50cdf0d..86295a49d 100644 --- a/GameFrameX.Network.Abstractions/MessageRpcMappingAttribute.cs +++ b/GameFrameX.Network.Abstractions/MessageRpcMappingAttribute.cs @@ -33,42 +33,44 @@ namespace GameFrameX.Network.Abstractions; /// RPC 消息属性标记。 /// /// -/// RPC message attribute marker. +/// RPC message attribute marker. Carries the request/response message types; the handler +/// type itself is validated at registration time to implement IMessageHandler with a public +/// parameterless constructor. /// [AttributeUsage(AttributeTargets.Class)] public class MessageRpcMappingAttribute : Attribute { /// - /// 构建 RPC 消息。 + /// 构建 RPC 消息映射。 /// /// /// Constructs the RPC message mapping attribute. /// - /// 请求消息类型 / Request message type - /// 返回消息类型 / Response message type - public MessageRpcMappingAttribute(IRequestMessage requestMessage, IResponseMessage responseMessage) + /// 请求消息类型 / Request message type + /// 返回消息类型 / Response message type + public MessageRpcMappingAttribute(Type requestMessageType, Type responseMessageType) { - ArgumentNullException.ThrowIfNull(requestMessage); - ArgumentNullException.ThrowIfNull(responseMessage); - RequestMessage = requestMessage; - ResponseMessage = responseMessage; + ArgumentNullException.ThrowIfNull(requestMessageType); + ArgumentNullException.ThrowIfNull(responseMessageType); + RequestMessageType = requestMessageType; + ResponseMessageType = responseMessageType; } /// - /// 获取请求消息。 + /// 获取请求消息类型。 /// /// - /// Gets the request message. + /// Gets the request message type. /// - /// 请求消息 / Request message - public IRequestMessage RequestMessage { get; } + /// 请求消息类型 / Request message type + public Type RequestMessageType { get; } /// - /// 获取响应消息。 + /// 获取响应消息类型。 /// /// - /// Gets the response message. + /// Gets the response message type. /// - /// 响应消息 / Response message - public IResponseMessage ResponseMessage { get; } -} \ No newline at end of file + /// 响应消息类型 / Response message type + public Type ResponseMessageType { get; } +} diff --git a/GameFrameX.Utility/Setting/AppSetting.cs b/GameFrameX.Utility/Setting/AppSetting.cs index 043231f43..5287a38ac 100644 --- a/GameFrameX.Utility/Setting/AppSetting.cs +++ b/GameFrameX.Utility/Setting/AppSetting.cs @@ -435,6 +435,34 @@ private static string MaskSensitiveValue(string value) [SettingFieldLevel(SettingFieldLevel.ProcessLevel)] public int ActorQueueTimeOut { get; set; } = 30_000; + /// + /// 是否启用热更程序集装载哈希校验(灰度开关),默认 false 不校验。 + /// 开启且装载参数配置了期望 SHA-256 时,热更装载前校验文件内容完整性,不匹配拒绝装载。 + /// 校验边界:仅覆盖主程序集单文件;依赖程序集不在校验范围(完整覆盖需运维侧哈希清单)。 + /// + /// + /// Whether to enable hotfix assembly load hash verification (gray switch). Default false (no verification). + /// When enabled and the load options carry an expected SHA-256, the assembly file content is verified + /// before loading; a mismatch rejects the load. Boundary: only the main assembly file is covered; + /// dependency assemblies are out of scope (full coverage needs the operations-side hash manifest). + /// + [Option(nameof(HotfixVerifyEnabled), DefaultValue = false, Description = "是否启用热更程序集装载哈希校验,默认值为 false(仅覆盖主程序集)")] + [SettingFieldLevel(SettingFieldLevel.ProcessLevel)] + public bool HotfixVerifyEnabled { get; set; } = false; + + /// + /// Actor 调用限制规则名(ActorLimit.RuleType 的字符串形式),默认 "None" 不检查。 + /// 规则一经配置即生效(生产可用),解析失败记录错误并回落 None。 + /// + /// + /// The Actor call limit rule name (string form of ActorLimit.RuleType). Default "None" (no checking). + /// A configured rule takes effect immediately (production usable); parse failures log an error and fall back to None. + /// + [Option(nameof(ActorLimitRule), DefaultValue = "None", Description = "Actor 调用限制规则名(None/NoBidirectionCall),默认值为 None")] + [SettingFieldLevel(SettingFieldLevel.ProcessLevel)] + public string ActorLimitRule { get; set; } = "None"; + + /// /// 是否启用TCP /// diff --git a/README.ja.md b/README.ja.md index 6fb199124..8d3b7b5ed 100644 --- a/README.ja.md +++ b/README.ja.md @@ -224,6 +224,8 @@ GameFrameX はコマンドライン引数(`--Key=Value`)で設定を行い | `ActorTimeOut` | Actor タスク実行タイムアウト(ミリ秒) | `30000` | `60000` | | `ActorQueueTimeOut` | Actor キュータイムアウト(ミリ秒) | `30000` | `60000` | | `ActorRecycleTime` | Actor アイドルリサイクル時間(分) | `15` | `30` | +| `ActorLimitRule` | Actor 呼び出し制限ルール(None/NoBidirectionCall) | `None` | `NoBidirectionCall` | +| `HotfixVerifyEnabled` | ホットフィックス アセンブリ読み込み SHA-256 検証 | `false` | `true` | | `SaveDataInterval` | データ保存間隔(ミリ秒) | `30000` | `60000` | | `SaveDataBatchCount` | バッチ保存数 | `500` | `1000` | | `SaveDataBatchTimeOut` | バッチ保存タイムアウト(ミリ秒) | `30000` | `60000` | diff --git a/README.ko.md b/README.ko.md index 43b21693b..dd006e52c 100644 --- a/README.ko.md +++ b/README.ko.md @@ -222,6 +222,8 @@ GameFrameX는 명령줄 인수(`--Key=Value`)로 설정합니다. 모든 설정 | `ActorTimeOut` | Actor 작업 실행 타임아웃(밀리초) | `30000` | `60000` | | `ActorQueueTimeOut` | Actor 큐 타임아웃(밀리초) | `30000` | `60000` | | `ActorRecycleTime` | Actor 유휴 리사이클 시간(분) | `15` | `30` | +| `ActorLimitRule` | Actor 호출 제한 규칙(None/NoBidirectionCall) | `None` | `NoBidirectionCall` | +| `HotfixVerifyEnabled` | 핫픽스 어셈블리 로드 SHA-256 검증 | `false` | `true` | | `SaveDataInterval` | 데이터 저장 간격(밀리초) | `30000` | `60000` | | `SaveDataBatchCount` | 배치 저장 수 | `500` | `1000` | | `SaveDataBatchTimeOut` | 배치 저장 타임아웃(밀리초) | `30000` | `60000` | diff --git a/README.md b/README.md index 2dd718be4..f030caa4c 100644 --- a/README.md +++ b/README.md @@ -251,6 +251,8 @@ GameFrameX uses command-line arguments (`--Key=Value`) for configuration. All co | `ActorTimeOut` | Actor task execution timeout (ms) | `30000` | `60000` | | `ActorQueueTimeOut` | Actor queue timeout (ms) | `30000` | `60000` | | `ActorRecycleTime` | Actor idle recycle time (minutes) | `15` | `30` | +| `ActorLimitRule` | Actor call limit rule (None/NoBidirectionCall) | `None` | `NoBidirectionCall` | +| `HotfixVerifyEnabled` | Hotfix assembly load SHA-256 verification | `false` | `true` | | `SaveDataInterval` | Data save interval (ms) | `30000` | `60000` | | `SaveDataBatchCount` | Batch save count | `500` | `1000` | | `SaveDataBatchTimeOut` | Batch save timeout (ms) | `30000` | `60000` | diff --git a/README.zh-CN.md b/README.zh-CN.md index ddfc31486..413c027cc 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -227,6 +227,8 @@ GameFrameX 使用命令行参数 (`--Key=Value`) 进行配置,所有配置项 | `ActorTimeOut` | Actor 任务执行超时(毫秒) | `30000` | `60000` | | `ActorQueueTimeOut` | Actor 队列超时(毫秒) | `30000` | `60000` | | `ActorRecycleTime` | Actor 空闲回收时间(分钟) | `15` | `30` | +| `ActorLimitRule` | Actor 调用限制规则(None/NoBidirectionCall) | `None` | `NoBidirectionCall` | +| `HotfixVerifyEnabled` | 热更程序集装载 SHA-256 校验开关 | `false` | `true` | | `SaveDataInterval` | 数据保存间隔(毫秒) | `30000` | `60000` | | `SaveDataBatchCount` | 批量保存数量 | `500` | `1000` | | `SaveDataBatchTimeOut` | 批量保存超时(毫秒) | `30000` | `60000` | diff --git a/README.zh-TW.md b/README.zh-TW.md index 2857929b5..5d8fa633d 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -222,6 +222,8 @@ GameFrameX 使用命令列參數 (`--Key=Value`) 進行配置,所有配置項 | `ActorTimeOut` | Actor 任務執行逾時(毫秒) | `30000` | `60000` | | `ActorQueueTimeOut` | Actor 佇列逾時(毫秒) | `30000` | `60000` | | `ActorRecycleTime` | Actor 閒置回收時間(分鐘) | `15` | `30` | +| `ActorLimitRule` | Actor 呼叫限制規則(None/NoBidirectionCall) | `None` | `NoBidirectionCall` | +| `HotfixVerifyEnabled` | 熱更程式集載入 SHA-256 驗證開關 | `false` | `true` | | `SaveDataInterval` | 資料儲存間隔(毫秒) | `30000` | `60000` | | `SaveDataBatchCount` | 批次儲存數量 | `500` | `1000` | | `SaveDataBatchTimeOut` | 批次儲存逾時(毫秒) | `30000` | `60000` | diff --git a/Server.slnx b/Server.slnx index 158d6139d..c90898761 100644 --- a/Server.slnx +++ b/Server.slnx @@ -11,6 +11,7 @@ + diff --git a/Tests/GameFrameX.Hotfix.Tests.Probes/GameFrameX.Hotfix.Tests.Probes.csproj b/Tests/GameFrameX.Hotfix.Tests.Probes/GameFrameX.Hotfix.Tests.Probes.csproj new file mode 100644 index 000000000..8a25a44cc --- /dev/null +++ b/Tests/GameFrameX.Hotfix.Tests.Probes/GameFrameX.Hotfix.Tests.Probes.csproj @@ -0,0 +1,16 @@ + + + + false + false + + false + false + + + + + + + + diff --git a/Tests/GameFrameX.Hotfix.Tests.Probes/HotfixRegistrationProbes.cs b/Tests/GameFrameX.Hotfix.Tests.Probes/HotfixRegistrationProbes.cs new file mode 100644 index 000000000..05390819f --- /dev/null +++ b/Tests/GameFrameX.Hotfix.Tests.Probes/HotfixRegistrationProbes.cs @@ -0,0 +1,430 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the People's Republic of China and relevant international regulations. +// 使用本项目须严格遵守相应法律法规与开源许可证之规定。 +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// 本项目采用 Apache License 2.0 单协议分发, +// This project is licensed solely under the Apache License 2.0, +// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 +// please see the LICENSE file in the root directory of the source code for the full license text. +// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 +// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order, +// 侵犯他人合法权益等法律法规所禁止的行为! +// or infringe upon the legal rights and interests of others, as prohibited by laws and regulations! +// 因基于本项目二次开发所产生的一切法律纠纷与责任, +// Any legal disputes or liabilities arising from secondary development based on this project +// 本组织与贡献者概不承担。 +// shall be borne solely by the developer; the project organization and contributors assume no responsibility. +// GitHub 仓库:https://github.com/GameFrameX +// GitHub Repository: https://github.com/GameFrameX +// Gitee 仓库:https://gitee.com/GameFrameX +// Gitee Repository: https://gitee.com/GameFrameX +// CNB 仓库:https://cnb.cool/GameFrameX +// CNB Repository: https://cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + +using GameFrameX.Apps.Player.Player.Component; +using GameFrameX.Core.Abstractions; +using GameFrameX.Core.Abstractions.Agent; +using GameFrameX.Core.Abstractions.Events; +using GameFrameX.Core.Components; +using GameFrameX.Core.Events; +using GameFrameX.Core.Hotfix.Agent; +using GameFrameX.Network.Abstractions; +using GameFrameX.Network.HTTP; + +namespace GameFrameX.Hotfix.Tests.Probes; + +/// +/// 热更注册契约探针集中地:独立于测试主程序集,任何测试不再对测试主程序集或本程序集做整集扫描构造, +/// 注册入口经反射直驱 + 探针类型逐个喂入,消除类型元数据顺序带来的不确定性。 +/// +public static class HotfixRegistrationProbes +{ + /// + /// 契约违规探针:直接实现 IComponentAgent(基类 object,非泛型基类),反推组件类型必须抛契约异常。 + /// + public sealed class DirectInterfaceComponentAgent : IComponentAgent + { + /// 代理占位成员。 + public long ActorId + { + get { throw new NotImplementedException(); } + } + + /// 代理占位成员。 + public IComponent Owner + { + get { throw new NotImplementedException(); } + } + + /// 代理占位成员。 + public ushort OwnerType + { + get { throw new NotImplementedException(); } + } + + /// 代理占位成员。 + public void SetOwner(IComponent owner) + { + throw new NotImplementedException(); + } + + /// 代理占位成员。 + public Task BeforeActivationAsync() + { + return Task.FromResult(true); + } + + /// 代理占位成员。 + public Task ActiveAsync() + { + return Task.FromResult(true); + } + + /// 代理占位成员。 + public Task AfterActivationAsync() + { + return Task.FromResult(true); + } + + /// 代理占位成员。 + public Task BeforeInActivationAsync() + { + return Task.FromResult(true); + } + + /// 代理占位成员。 + public Task InactiveAsync() + { + return Task.CompletedTask; + } + + /// 代理占位成员。 + public Task AfterInActivationAsync() + { + return Task.FromResult(true); + } + + /// 代理占位成员。 + public void Tell(Action work, int timeOut = -1, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + /// 代理占位成员。 + public void Tell(Func work, int timeOut = -1, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + /// 代理占位成员。 + public Task SendAsync(Action work, int timeOut = -1, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + /// 代理占位成员。 + public Task SendAsync(Func work, int timeOut = -1, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + /// 代理占位成员。 + public Task SendAsync(Func work, int timeOut = -1, bool checkLock = true, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + /// 代理占位成员。 + public Task SendAsync(Func> work, int timeOut = -1, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + /// 代理占位成员。 + public Task GetComponentAgentAsync(Type agentType, bool isNew = true) + { + throw new NotImplementedException(); + } + + /// 代理占位成员。 + public Task GetComponentAgentAsync(bool isNew = true) where T : IComponentAgent + { + throw new NotImplementedException(); + } + } + + /// + /// 注册期构造校验探针:组件代理仅提供带参构造,故意不提供公共无参构造函数。 + /// + public sealed class MissingParameterlessConstructorComponentAgent : BaseComponentAgent + { + /// + /// 唯一带参构造:使本类型在注册期构造校验中失败(无公共无参构造函数)。 + /// + /// 占位构造参数(无实际用途)。 + public MissingParameterlessConstructorComponentAgent(int constructorValue) + { + } + } + + /// + /// 未注册组件探针:不携带 ComponentType 特性且不进入任何 ComponentRegister.InitAsync 扫描面。 + /// + public sealed class UnregisteredProbeComponent : BaseComponent + { + } + + /// + /// 指向未注册组件的代理探针。 + /// + public sealed class UnregisteredProbeComponentAgent : BaseComponentAgent + { + } + + /// + /// 探针事件参数。 + /// + public sealed class ProbeEventArgs : GameEventArgs + { + } + + /// + /// AddEvent 反查探针:监听未注册组件的代理,组件→Actor 反查必须抛带组件全名的契约异常。 + /// + [Event(typeof(ProbeEventArgs))] + public sealed class UnregisteredComponentEventListener : EventListener + { + /// 探针空实现(反查在监听器实例化之前失败,本方法不会被调用)。 + protected override Task HandleEventAsync(UnregisteredProbeComponentAgent agent, GameEventArgs gameEventArgs) + { + return Task.CompletedTask; + } + } + + /// + /// 探针消息类型一:携带消息号特性供映射注册读取。 + /// + [MessageTypeHandler(ProbeFirstMessageId)] + public sealed class FirstProbeMessage + { + } + + /// + /// 探针消息类型二。 + /// + [MessageTypeHandler(ProbeSecondMessageId)] + public sealed class SecondProbeMessage + { + } + + /// + /// 同消息号探针消息一(与 共享消息号)。 + /// + [MessageTypeHandler(ProbeDuplicateMessageId)] + public sealed class DuplicateMessageIdFirstProbeMessage + { + } + + /// + /// 同消息号探针消息二。 + /// + [MessageTypeHandler(ProbeDuplicateMessageId)] + public sealed class DuplicateMessageIdSecondProbeMessage + { + } + + /// + /// 注册期校验探针一:带 [MessageMapping] 但不实现 IMessageHandler。 + /// + [MessageMapping(typeof(FirstProbeMessage))] + public sealed class NotMessageHandlerProbeHandler + { + } + + /// + /// 注册期校验探针二:实现 IMessageHandler 但仅提供带参构造,故意不提供公共无参构造函数。 + /// + [MessageMapping(typeof(SecondProbeMessage))] + public sealed class MissingParameterlessConstructorMessageHandler : IMessageHandler + { + /// + /// 唯一带参构造:使本类型在注册期构造校验中失败(无公共无参构造函数)。 + /// + /// 占位构造参数(无实际用途)。 + public MissingParameterlessConstructorMessageHandler(int constructorValue) + { + } + + /// 探针空实现。 + public Task InitAsync(INetworkMessage message, INetworkChannel networkChannel) + { + return Task.FromResult(true); + } + + /// 探针空实现。 + public Task InnerActionAsync(int timeout = 30000, CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + + /// 探针消息类型。 + public Type GetMessageType() + { + return typeof(SecondProbeMessage); + } + + /// 探针响应类型。 + public Type GetResponseType() + { + return typeof(SecondProbeMessage); + } + } + + /// + /// 同消息号冲突探针处理器一(合法可实例化)。 + /// + [MessageMapping(typeof(DuplicateMessageIdFirstProbeMessage))] + public sealed class DuplicateMessageIdFirstComponentHandler : ValidProbeMessageHandler + { + } + + /// + /// 同消息号冲突探针处理器二(合法可实例化,消息号与处理器一相同)。 + /// + [MessageMapping(typeof(DuplicateMessageIdSecondProbeMessage))] + public sealed class DuplicateMessageIdSecondComponentHandler : ValidProbeMessageHandler + { + } + + /// + /// 合法消息处理器探针基类(满足密封/后缀/无参构造契约)。 + /// + public abstract class ValidProbeMessageHandler : IMessageHandler + { + /// 探针空实现。 + public Task InitAsync(INetworkMessage message, INetworkChannel networkChannel) + { + return Task.FromResult(true); + } + + /// 探针空实现。 + public Task InnerActionAsync(int timeout = 30000, CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + + /// 探针消息类型。 + public Type GetMessageType() + { + return typeof(FirstProbeMessage); + } + + /// 探针响应类型。 + public Type GetResponseType() + { + return typeof(FirstProbeMessage); + } + } + + /// + /// RPC 探针请求消息类型一。 + /// + public sealed class FirstRpcProbeRequestMessage + { + } + + /// + /// RPC 探针响应消息类型一。 + /// + public sealed class FirstRpcProbeResponseMessage + { + } + + /// + /// 合法 RPC 处理器探针(无参构造可实例化)。 + /// + [MessageRpcMapping(typeof(FirstRpcProbeRequestMessage), typeof(FirstRpcProbeResponseMessage))] + public sealed class ValidRpcProbeHandler : ValidProbeMessageHandler + { + } + + /// + /// RPC 重复注册探针:与 映射同一请求消息类型。 + /// + [MessageRpcMapping(typeof(FirstRpcProbeRequestMessage), typeof(FirstRpcProbeResponseMessage))] + public sealed class DuplicateRpcProbeHandler : ValidProbeMessageHandler + { + } + /// + /// 全小写命令 HTTP 探针:类名去掉 HttpHandler 后缀的 OriginalCmd 与 StandardCmd 完全相等, + /// 单命令只应注册一次(旧实现第二次 TryAdd 同键失败抛"重复注册")。 + /// + [HttpMessageMapping(typeof(statusHttpHandler))] + public sealed class statusHttpHandler : BaseHttpHandler + { + } + + /// + /// 大小写变体 HTTP 探针:OriginalCmd 含大写字母(PlayerInfo / player_info 双命令), + /// 大小写变体查询必须命中同一处理器(旧字典区分大小写返回 null)。 + /// + [HttpMessageMapping(typeof(PlayerInfoHttpHandler))] + public sealed class PlayerInfoHttpHandler : BaseHttpHandler + { + } + + /// + /// RPC 注册期构造校验探针:仅提供带参构造,注册期 CreateFactory 必须抛出。 + /// + [MessageRpcMapping(typeof(FirstRpcProbeRequestMessage), typeof(FirstRpcProbeResponseMessage))] + public sealed class MissingParameterlessConstructorRpcProbeHandler : IMessageHandler + { + /// + /// 唯一带参构造:使本类型在注册期构造校验中失败(无公共无参构造函数)。 + /// + /// 占位构造参数(无实际用途)。 + public MissingParameterlessConstructorRpcProbeHandler(int constructorValue) + { + } + + /// 探针空实现。 + public Task InitAsync(INetworkMessage message, INetworkChannel networkChannel) + { + return Task.FromResult(true); + } + + /// 探针空实现。 + public Task InnerActionAsync(int timeout = 30000, CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + + /// 探针消息类型。 + public Type GetMessageType() + { + return typeof(FirstRpcProbeRequestMessage); + } + + /// 探针响应类型。 + public Type GetResponseType() + { + return typeof(FirstRpcProbeResponseMessage); + } + } + + + /// 探针消息号一(不参与任何真实协议派发)。 + public const int ProbeFirstMessageId = 990_101; + + /// 探针消息号二。 + public const int ProbeSecondMessageId = 990_102; + + /// 同消息号探针共享的消息号。 + public const int ProbeDuplicateMessageId = 990_103; +} diff --git a/Tests/GameFrameX.Hotfix.Tests/AgentDelayTimestampSchedulingTests.cs b/Tests/GameFrameX.Hotfix.Tests/AgentDelayTimestampSchedulingTests.cs new file mode 100644 index 000000000..be5832608 --- /dev/null +++ b/Tests/GameFrameX.Hotfix.Tests/AgentDelayTimestampSchedulingTests.cs @@ -0,0 +1,229 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the People's Republic of China and related international regulations. +// 使用本项目须严格遵守相应法律法规与开源许可证之规定。 +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// 本项目采用 Apache License 2.0 单协议分发, +// This project is licensed solely under the Apache License 2.0, +// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 +// please see the LICENSE file in the root directory of the source code for the full license text. +// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 +// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order, +// 侵犯他人合法权益等法律法规所禁止的行为! +// or infringe upon the legal rights and interests of others, as prohibited by laws and regulations! +// 因基于本项目二次开发所产生的一切法律纠纷与责任, +// Any legal disputes and liabilities arising from secondary development based on this project +// 本组织与贡献者概不承担。 +// shall be borne solely by the developer; the project organization and contributors assume no responsibility. +// GitHub 仓库:https://github.com/GameFrameX +// GitHub Repository: https://github.com/GameFrameX +// Gitee 仓库:https://gitee.com/GameFrameX +// Gitee Repository: https://gitee.com/GameFrameX +// CNB 仓库:https://cnb.cool/GameFrameX +// CNB Repository: https://cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + +using System.Reflection; +using GameFrameX.Core.Abstractions.Hotfix; +using GameFrameX.Core.Abstractions.Agent; +using GameFrameX.Core.Abstractions.Events; +using GameFrameX.Core.Actors; +using GameFrameX.Core.Components; +using GameFrameX.Core.Hotfix; +using GameFrameX.Core.Hotfix.Agent; +using GameFrameX.Core.Timer; +using GameFrameX.Core.Timer.Handler; +using GameFrameX.Foundation.Utility; +using GameFrameX.Utility.Setting; +using Quartz; +using Xunit; + +namespace GameFrameX.Hotfix.Tests; + +/// +/// 组件代理时间戳延迟调度语义回归:DelayAsync<T>(long) 的 time 按 Unix 毫秒时间戳解释。 +/// +/// +/// 旧实现把 Unix 时间戳当 Ticks 构造 DateTime,触发点落在 0001 年附近,misfire 立即触发并自删作业—— +/// 定时任务永不按预期触达。以 GetJobDetail 存在性探测断言:+250ms 探测时作业必须仍在调度中(未触发未删除)。 +/// +public sealed class AgentDelayTimestampSchedulingTests +{ + /// 调度目标偏移(毫秒):足够远以覆盖断言窗口,足够近以约束用例时长。 + private const int ScheduleTargetOffsetMilliseconds = 1_500; + + /// 断言探测等待(毫秒):远小于调度目标,作业必须仍未触发。 + private const int ProbeWaitMilliseconds = 250; + + [Fact] + public async Task DelayAsync_ByUnixMillisecondTimestamp_KeepsJobPendingUntilDue_AndRemovesAfterFiring() + { + await QuartzTimer.StartAsync(); + HotfixManager.SetResolverForTesting(new NullHotfixResolver()); + try + { + var agent = CreateProbeComponentAgent(); + var dueTimestamp = TimerHelper.UnixTimeMilliseconds() + ScheduleTargetOffsetMilliseconds; + + var scheduleId = await agent.DelayAsync(dueTimestamp); + Assert.True(scheduleId > 0); + + var scheduler = GetScheduler(); + try + { + // 到期前作业必须仍在调度中:旧实现按 Ticks 解释触发点落在 0001 年附近(早已过期), + // 或经时区换算落在过去——两者都会提前离开"挂起"状态(确定性红) + await Task.Delay(ProbeWaitMilliseconds); + Assert.NotNull(await scheduler.GetJobDetail(new JobKey(scheduleId.ToString()))); + + // 到期后作业触发并完成自删(一次性作业非持久化):验证触发时刻确实由时间戳决定 + var removalDeadline = DateTime.UtcNow + TimeSpan.FromSeconds(5); + while (DateTime.UtcNow < removalDeadline + && await scheduler.GetJobDetail(new JobKey(scheduleId.ToString())) != null) + { + await Task.Delay(50); + } + + Assert.Null(await scheduler.GetJobDetail(new JobKey(scheduleId.ToString()))); + } + finally + { + await QuartzTimer.RemoveAsync(scheduleId); + } + } + finally + { + HotfixManager.SetResolverForTesting(null); + } + } + + /// + /// 构造挂在真实 Actor 上的探针组件代理(ActorLimit/WorkerActor 均不介入,仅承载 ActorId 与调度集合)。 + /// + private static TimestampDelayComponentAgent CreateProbeComponentAgent() + { + if (GlobalSettings.CurrentSetting == null) + { + GlobalSettings.SetCurrentSetting(new AppSetting { ServerId = 1 }); + } + + // 类型取 200(全局段):构造器的组件激活 Tell 对未注册类型仅记录 Fatal 日志后返回,无副作用 + var actor = new Actor(990_099, 200); + var component = new TimestampDelayProbeComponent { Actor = actor, }; + var agent = new TimestampDelayComponentAgent(); + agent.SetOwner(component); + return agent; + } + + /// + /// 取 QuartzTimer 私有调度器实例(GetJobDetail 存在性探测用)。 + /// + private static IScheduler GetScheduler() + { + var schedulerField = typeof(QuartzTimer).GetField("_scheduler", BindingFlags.Static | BindingFlags.NonPublic); + Assert.NotNull(schedulerField); + return Assert.IsAssignableFrom(schedulerField.GetValue(null)); + } + + /// 探针组件(无任何状态语义,仅承载 Actor 归属)。 + private sealed class TimestampDelayProbeComponent : BaseComponent + { + } + + /// 探针组件代理(仅使用 DelayAsync 调度面)。 + private sealed class TimestampDelayComponentAgent : BaseComponentAgent + { + } + + /// 探针定时处理器(触发前即被移除,处理体不被调用)。 + private sealed class TimestampDelayTimerHandler : ITimerHandler + { + /// 探针空实现。 + public Task InnerHandleTimerAsync(IComponentAgent agent, GameEventArgs gameEventArgs) + { + return Task.CompletedTask; + } + } + + /// + /// 测试用解析契约替身:HotfixAssembly 指向测试程序集,其余成员返回空值(本测试不让作业触发)。 + /// + private sealed class NullHotfixResolver : IHotfixResolver + { + /// 测试程序集。 + public Assembly HotfixAssembly + { + get { return typeof(NullHotfixResolver).Assembly; } + } + + /// 不在切换中。 + public bool InTransition + { + get { return false; } + } + + /// 无代理类型。 + public Type GetAgentType(Type componentType) + { + return null; + } + + /// 无组件类型。 + public Type GetComponentType(Type agentType) + { + return null; + } + + /// 无代理实例。 + public T GetAgent(BaseComponent component, Type refAssemblyType) where T : class, IComponentAgent + { + return null; + } + + /// 无 RPC 处理器。 + public Network.Abstractions.IMessageHandler GetRpcHandler(Type requestType) + { + return null; + } + + /// 无消息处理器。 + public Network.Abstractions.IMessageHandler GetMessageHandler(int messageId) + { + return null; + } + + /// 无 HTTP 处理器。 + public Network.HTTP.BaseHttpHandler GetHttpHandler(string command) + { + return null; + } + + /// 无 HTTP 处理器列表。 + public List GetListHttpHandler() + { + return new List(); + } + + /// 无监听者。 + public List FindListeners(ushort actorType, Type eventArgsType) + { + return new List(); + } + + /// 无监听者。 + public List FindListeners(Type eventArgsType) + { + return new List(); + } + + /// 无实例。 + public T GetInstance(string typeName, Type refAssemblyType = null) + { + return default; + } + } +} diff --git a/Tests/GameFrameX.Hotfix.Tests/GameFrameX.Hotfix.Tests.csproj b/Tests/GameFrameX.Hotfix.Tests/GameFrameX.Hotfix.Tests.csproj index c64c8c703..a9ce58c55 100644 --- a/Tests/GameFrameX.Hotfix.Tests/GameFrameX.Hotfix.Tests.csproj +++ b/Tests/GameFrameX.Hotfix.Tests/GameFrameX.Hotfix.Tests.csproj @@ -24,6 +24,7 @@ +