From dc98ab8bcbccbb60e72ba1d0d904c08e226e18b3 Mon Sep 17 00:00:00 2001 From: Blank Date: Sun, 1 Nov 2026 17:29:12 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(core):=20=E7=83=AD=E6=9B=B4=E6=B3=A8?= =?UTF-8?q?=E5=86=8C=E5=A5=91=E7=BA=A6=E3=80=81=E8=A3=85=E8=BD=BD=E4=BF=A1?= =?UTF-8?q?=E4=BB=BB=E4=B8=8E=E8=A1=8C=E4=B8=BA=E4=B8=80=E8=87=B4=E6=80=A7?= =?UTF-8?q?=2013=20=E9=A1=B9=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 组件类型反推五路径统一守卫(契约异常替代越界异常)并使 AddEvent 组件反查带上下文;RPC 注册面工厂化(注册期 CreateFactory 校验、同请求重复注册 fail-fast)、消息号冲突 fail-fast、HTTP 命令字典大小写不敏感且全小写命令单次注册; 热更依赖随可收集上下文同生共死(每次装载、判重只看默认 ALC);装载前 SHA-256 哈希校验灰度开关(默认关闭、仅覆盖 主程序集);DelayAsync 时间戳改 UTC 同基解释;跨天第二段 独立计时;组件 RPC 超时回写 TimeOut 响应且孤儿未终态不写 共享实例;ActorLimit 收敛为计数式在飞登记(EndCall 移除、 空集合回收、登记所有权标志、规则配置化默认 None、8 调用点 拒绝异常化);未登录玩家消息删除随机 ActorId 回退; ComponentRegister 初始化加锁;新增本地化键六条双语。 BREAKING CHANGE: MessageRpcMappingAttribute 构造参数由 (IRequestMessage, IResponseMessage) 改为 (Type, Type)——原 形态因特性参数类型限制(CS0181)从未可用,仓内零存量消费者。 Linear: GFX-1177 --- GameFrameX.Core/Actors/Actor.cs | 3 +- GameFrameX.Core/Actors/ActorManager.cs | 5 +- GameFrameX.Core/Actors/Impl/ActorLimit.cs | 127 ++++++++-------- GameFrameX.Core/Actors/Impl/WorkWrapper.cs | 12 ++ GameFrameX.Core/Actors/Impl/WorkerActor.cs | 97 ++++++++++--- .../BaseHandler/GlobalComponentHandler.cs | 3 +- .../BaseHandler/GlobalRpcComponentHandler.cs | 3 +- .../BaseHandler/PlayerComponentHandler.cs | 9 +- .../BaseHandler/PlayerRpcComponentHandler.cs | 9 +- .../RPC/BaseRpcComponentHandler.cs | 67 ++++++++- .../Components/ComponentRegister.cs | 53 ++++--- .../Agent/BaseComponentAgent.QuartzTimer.cs | 8 +- GameFrameX.Core/Hotfix/HotfixLoadOptions.cs | 13 ++ GameFrameX.Core/Hotfix/HotfixManager.cs | 4 +- GameFrameX.Core/Hotfix/HotfixModule.cs | 136 +++++++++++++----- .../StartUp/AppStartUpGameServerBase.cs | 10 +- GameFrameX.Localization/Keys.Core.cs | 60 ++++++++ .../Localization/Messages/Resources.resx | 18 +++ .../Messages/Resources.zh-CN.resx | 18 +++ .../MessageRpcMappingAttribute.cs | 38 ++--- GameFrameX.Utility/Setting/AppSetting.cs | 28 ++++ 21 files changed, 543 insertions(+), 178 deletions(-) diff --git a/GameFrameX.Core/Actors/Actor.cs b/GameFrameX.Core/Actors/Actor.cs index afa827d25..82d7feb09 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.Foundation.Logger; using GameFrameX.Foundation.Localization.Core; using GameFrameX.Utility.Setting; @@ -153,7 +154,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 d84ea26b6..173e010ed 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 0c607e3c1..28e5c39b4 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); } @@ -296,6 +322,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) { @@ -309,6 +340,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); @@ -321,6 +357,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); @@ -353,7 +394,7 @@ public static long NextChainId() /// /// 要执行的同步Action工作单元 /// 用于追踪任务调用链的唯一标识符 - /// 是否忽略调试模式下的调用限制检查,true表示强制执行 + /// 是否跳过调用限制检查,true表示跳过 /// 任务执行的超时时间(毫秒),默认为-1,将采用配置时间ActorQueueTimeOut /// 用于取消任务的令牌 /// 返回一个Task对象,表示异步操作的完成状态 @@ -364,9 +405,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) @@ -379,6 +420,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; @@ -390,7 +433,7 @@ public Task EnqueueAsync(Action work, long callChainId, bool discard = false, in /// 函数返回值的类型 /// 要执行的同步Func工作单元 /// 用于追踪任务调用链的唯一标识符 - /// 是否忽略调试模式下的调用限制检查,true表示强制执行 + /// 是否跳过调用限制检查,true表示跳过 /// 任务执行的超时时间(毫秒),默认为-1,将采用配置时间ActorQueueTimeOut /// 用于取消任务的令牌 /// 返回一个Task对象,表示异步操作的完成状态和结果 @@ -401,9 +444,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) @@ -416,6 +459,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; @@ -426,7 +471,7 @@ public Task EnqueueAsync(Func work, long callChainId, bool discard = fa /// /// 要执行的异步Task工作单元 /// 用于追踪任务调用链的唯一标识符 - /// 是否忽略调试模式下的调用限制检查,true表示强制执行 + /// 是否跳过调用限制检查,true表示跳过 /// 任务执行的超时时间(毫秒),默认为-1,将采用配置时间ActorQueueTimeOut /// 用于取消任务的令牌 /// 返回一个Task对象,表示异步操作的完成状态 @@ -437,9 +482,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) @@ -452,6 +497,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; @@ -463,7 +510,7 @@ public Task EnqueueAsync(Func work, long callChainId, bool discard = false /// 异步任务返回值的类型 /// 要执行的异步Task工作单元 /// 用于追踪任务调用链的唯一标识符 - /// 是否忽略调试模式下的调用限制检查,true表示强制执行 + /// 是否跳过调用限制检查,true表示跳过 /// 任务执行的超时时间(毫秒),默认为-1,将采用配置时间ActorQueueTimeOut /// 用于取消任务的令牌 /// 返回一个Task对象,表示异步操作的完成状态和结果 @@ -474,9 +521,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) @@ -489,6 +536,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; @@ -574,9 +623,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) @@ -589,6 +638,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; @@ -616,9 +667,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) @@ -631,6 +682,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; @@ -674,9 +727,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) @@ -689,6 +742,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; @@ -715,9 +770,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) @@ -730,6 +785,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 e0e905d05..67303557a 100644 --- a/GameFrameX.Core/BaseHandler/RPC/BaseRpcComponentHandler.cs +++ b/GameFrameX.Core/BaseHandler/RPC/BaseRpcComponentHandler.cs @@ -131,25 +131,54 @@ 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) @@ -167,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; @@ -176,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 08bce5849..ca20cab8f 100644 --- a/GameFrameX.Core/Hotfix/HotfixLoadOptions.cs +++ b/GameFrameX.Core/Hotfix/HotfixLoadOptions.cs @@ -77,6 +77,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 1b64068a4..28d10c26b 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 e68853ec9..88dcdb08a 100644 --- a/GameFrameX.Localization/Keys.Core.cs +++ b/GameFrameX.Localization/Keys.Core.cs @@ -634,6 +634,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} /// @@ -859,6 +869,16 @@ public static class WorkerActor /// 参数: {0} - 获取追踪信息时发生的异常消息 /// 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"; } /// /// 定时器相关异常 @@ -1069,6 +1089,46 @@ public static class HotfixModule /// 参数: {0} - 实现类型全名, {1} - 服务类型全名 /// 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"; } /// diff --git a/GameFrameX.Localization/Localization/Messages/Resources.resx b/GameFrameX.Localization/Localization/Messages/Resources.resx index a048fd266..2b5c39fbf 100644 --- a/GameFrameX.Localization/Localization/Messages/Resources.resx +++ b/GameFrameX.Localization/Localization/Messages/Resources.resx @@ -548,6 +548,9 @@ trace unavailable: {0} + + WorkerActor work call rejected by limit rule, caller actorId:{0} target actorId:{1} + component:[{0}] the actor type is not bound @@ -603,6 +606,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} + Invalid component type {0}, value should be greater than {1} and less than {2} and not equal to {3} @@ -1743,6 +1758,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 f6ec046d9..71bfb5d13 100644 --- a/GameFrameX.Localization/Localization/Messages/Resources.zh-CN.resx +++ b/GameFrameX.Localization/Localization/Messages/Resources.zh-CN.resx @@ -566,6 +566,9 @@ trace不可用:{0} + + WorkerActor工作调用被限流规则拒绝,调用方ActorId:{0} 目标ActorId:{1} + 组件:[{0}] Actor类型未绑定 @@ -624,6 +627,18 @@ {0} 必须是实现 {1} 的非抽象封闭类型,且具有公共无参构造函数 + + 组件代理基类型无效,无法推导组件类型: {0} + + + RPC处理器重复注册,请求消息类型:{0} 处理器类型:{1} + + + 消息号重复注册,消息号:{0} 处理器类型:{1} + + + 热更程序集哈希校验失败,路径:{0} 期望SHA-256:{1} 实际SHA-256:{2} + 无效的组件类型 {0},值应大于{1}且小于{2}和不为{3} @@ -1737,6 +1752,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 fee60ea35..7bac21981 100644 --- a/GameFrameX.Utility/Setting/AppSetting.cs +++ b/GameFrameX.Utility/Setting/AppSetting.cs @@ -387,6 +387,34 @@ public string ToFormatString() [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 /// From a0354843f9e60ded59725f189c2917b679d29938 Mon Sep 17 00:00:00 2001 From: Blank Date: Sun, 1 Nov 2026 17:29:18 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat(core):=20=E6=B3=A8=E5=86=8C=E5=A5=91?= =?UTF-8?q?=E7=BA=A6=E5=9B=9E=E5=BD=92=E6=B5=8B=E8=AF=95=E4=B8=8E=E7=8B=AC?= =?UTF-8?q?=E7=AB=8B=E6=8E=A2=E9=92=88=E9=A1=B9=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新建 Tests/GameFrameX.Hotfix.Tests.Probes 探针项目集中放置 契约探针,两项 ThrowsFast 既有用例改干净程序集模块 + 反射 直驱注册入口(顺序无关确定性);新增注册契约、哈希校验、 时间戳调度、ActorLimit 在飞登记、未登录玩家拒绝、组件 RPC 超时回写等 13 项回归;池归还断言改持有式排空(消除快槽 乒乓导致的偶发红)。 Linear: GFX-1177 --- Server.slnx | 1 + .../GameFrameX.Hotfix.Tests.Probes.csproj | 16 + .../HotfixRegistrationProbes.cs | 430 ++++++++++++++++++ .../AgentDelayTimestampSchedulingTests.cs | 229 ++++++++++ .../GameFrameX.Hotfix.Tests.csproj | 1 + ...tfixModuleAgentFactoryRegistrationTests.cs | 77 +++- .../HotfixModuleMessageHandlerFactoryTests.cs | 167 +++---- .../HotfixModuleRegistrationContractTests.cs | 315 +++++++++++++ .../ActorLimitInFlightRegistrationTests.cs | 122 +++++ .../BaseRpcMessageHandlerPoolReturnTests.cs | 28 +- .../PlayerHandlerNoActorIdTests.cs | 241 ++++++++++ .../RpcComponentTimeoutWritebackTests.cs | 374 +++++++++++++++ 12 files changed, 1860 insertions(+), 141 deletions(-) create mode 100644 Tests/GameFrameX.Hotfix.Tests.Probes/GameFrameX.Hotfix.Tests.Probes.csproj create mode 100644 Tests/GameFrameX.Hotfix.Tests.Probes/HotfixRegistrationProbes.cs create mode 100644 Tests/GameFrameX.Hotfix.Tests/AgentDelayTimestampSchedulingTests.cs create mode 100644 Tests/GameFrameX.Hotfix.Tests/HotfixModuleRegistrationContractTests.cs create mode 100644 Tests/GameFrameX.Tests/Core/Actors/ActorLimitInFlightRegistrationTests.cs create mode 100644 Tests/GameFrameX.Tests/Core/BaseHandler/PlayerHandlerNoActorIdTests.cs create mode 100644 Tests/GameFrameX.Tests/Core/BaseHandler/RpcComponentTimeoutWritebackTests.cs 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 @@ +