From e105c27743e364d66cdc0145d88f12df82932884 Mon Sep 17 00:00:00 2001 From: Blank Date: Sat, 31 Oct 2026 21:31:57 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(core):=20=E9=85=8D=E7=BD=AE=E8=A1=A8?= =?UTF-8?q?=E8=BE=B9=E7=95=8C=E4=B8=8E=E6=B4=BE=E7=94=9F=E7=BC=93=E5=AD=98?= =?UTF-8?q?=E8=AF=AD=E4=B9=89=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按审查 CR-M08/L02/L03/L04:ByteBuf.ReadSegment 三个多字节头分支 段长前置校验(long 算术防溢出出负),损坏数据统一抛 SerializationException 而非越界异常;BaseDataTable.Max/Min 空表 返回默认值,Count 以 DataList 为事实源且空表缓存一次置位; StringUtil.ToStr 跳过无 getter 与索引器属性;TimeOutAttribute 的 TimeSpan 构造改用总毫秒数。附 A/B 回归测试。 Linear: GFX-1178 --- .../Attribute/TimeOutAttribute.cs | 4 +- GameFrameX.Core/Config/BaseDataTable.cs | 31 ++++-- GameFrameX.Core/Config/ByteBuf.cs | 28 +++++ GameFrameX.Core/Config/StringUtil.cs | 16 ++- .../Config/ByteBufReadSegmentTests.cs | 91 ++++++++++++++++ .../Config/StringUtilToStrTests.cs | 84 +++++++++++++++ .../BaseDataTableCacheInvalidationTests.cs | 100 ++++++++++++++++++ .../Core/TimeOutAttributeTests.cs | 65 ++++++++++++ 8 files changed, 403 insertions(+), 16 deletions(-) create mode 100644 Tests/GameFrameX.Tests/Config/ByteBufReadSegmentTests.cs create mode 100644 Tests/GameFrameX.Tests/Config/StringUtilToStrTests.cs create mode 100644 Tests/GameFrameX.Tests/Core/TimeOutAttributeTests.cs diff --git a/GameFrameX.Core/Abstractions/Attribute/TimeOutAttribute.cs b/GameFrameX.Core/Abstractions/Attribute/TimeOutAttribute.cs index 4ff7dd1c8..07dfac110 100644 --- a/GameFrameX.Core/Abstractions/Attribute/TimeOutAttribute.cs +++ b/GameFrameX.Core/Abstractions/Attribute/TimeOutAttribute.cs @@ -45,10 +45,10 @@ public TimeOutAttribute(int timeOut) } /// - /// 超时时间 + /// 超时时间(取 TimeSpan 的总毫秒数,而非 0-999 的毫秒分量) /// /// - public TimeOutAttribute(TimeSpan timeOut) : this(timeOut.Milliseconds) + public TimeOutAttribute(TimeSpan timeOut) : this((int)timeOut.TotalMilliseconds) { } diff --git a/GameFrameX.Core/Config/BaseDataTable.cs b/GameFrameX.Core/Config/BaseDataTable.cs index 065aec914..c22601400 100644 --- a/GameFrameX.Core/Config/BaseDataTable.cs +++ b/GameFrameX.Core/Config/BaseDataTable.cs @@ -299,12 +299,19 @@ public void ForEach(Action func) /// /// /// Computes the projection value for all objects using the specified function and returns the maximum value. + /// 空表返回 default(Tk),不抛异常。 + /// An empty table returns default(Tk) instead of throwing. /// /// 投影值的类型,必须实现 IComparable 接口 / The type of the projection value, must implement IComparable /// 应用于每个对象的投影函数 / The projection function to apply to each object - /// 所有对象投影值中的最大值 / The maximum value among all projected values + /// 所有对象投影值中的最大值;空表返回 default(Tk) / The maximum value among all projected values; default(Tk) for an empty table public Tk Max(Func func) { + if (DataList.Count == 0) + { + return default; + } + return DataList.Max(func); } @@ -313,12 +320,19 @@ public Tk Max(Func func) /// /// /// Computes the projection value for all objects using the specified function and returns the minimum value. + /// 空表返回 default(Tk),不抛异常。 + /// An empty table returns default(Tk) instead of throwing. /// /// 投影值的类型,必须实现 IComparable 接口 / The type of the projection value, must implement IComparable /// 应用于每个对象的投影函数 / The projection function to apply to each object - /// 所有对象投影值中的最小值 / The minimum value among all projected values + /// 所有对象投影值中的最小值;空表返回 default(Tk) / The minimum value among all projected values; default(Tk) for an empty table public Tk Min(Func func) { + if (DataList.Count == 0) + { + return default; + } + return DataList.Min(func); } @@ -405,6 +419,8 @@ private void EnsureFirstLastCache() if (DataList.Count == 0) { + // 空表也置位缓存标志:首尾缓存结果(null)一次落定,不再每次访问重算 + _cacheInitialized = true; return; } @@ -444,14 +460,9 @@ private void EnsureCountCache() return; } - var count = Math.Max(LongDataMaps.Count, StringDataMaps.Count); - if (count == 0) - { - _countCache = 0; - return; - } - - _countCache = count; + // 条目数事实源是 DataList:两键字典可能只登记部分键型,取字典 Max 会低估条目数; + // 空表同样置位标志,避免每次访问重算 + _countCache = DataList.Count; _countCacheInitialized = true; } diff --git a/GameFrameX.Core/Config/ByteBuf.cs b/GameFrameX.Core/Config/ByteBuf.cs index 691e7b091..ddb0876d1 100644 --- a/GameFrameX.Core/Config/ByteBuf.cs +++ b/GameFrameX.Core/Config/ByteBuf.cs @@ -1877,6 +1877,15 @@ public void ReadSegment(out int startIndex, out int segmentSize) { EnsureRead(1); segmentSize = ((h & 0x3f) << 8) | Bytes[ReaderIndex]; + + // 段长来自数据本身:先校验可读范围再搬运尾部字节(long 算术防 int 溢出出负), + // 损坏数据统一抛 SerializationException 而非越界异常 + // Localization: CoreConfig.ByteBuf.SegmentDataNotEnough - 段数据不足 + if ((long)ReaderIndex + segmentSize + 1 > WriterIndex) + { + throw new SerializationException(LocalizationService.GetString(Localization.Keys.CoreConfig.SegmentDataNotEnough)); + } + var endPos = ReaderIndex + segmentSize; Bytes[ReaderIndex] = Bytes[endPos]; ReaderIndex += segmentSize + 1; @@ -1885,6 +1894,15 @@ public void ReadSegment(out int startIndex, out int segmentSize) { EnsureRead(2); segmentSize = ((h & 0x1f) << 16) | ((int)Bytes[ReaderIndex] << 8) | Bytes[ReaderIndex + 1]; + + // 段长来自数据本身:先校验可读范围再搬运尾部字节(long 算术防 int 溢出出负), + // 损坏数据统一抛 SerializationException 而非越界异常 + // Localization: CoreConfig.ByteBuf.SegmentDataNotEnough - 段数据不足 + if ((long)ReaderIndex + segmentSize + 2 > WriterIndex) + { + throw new SerializationException(LocalizationService.GetString(Localization.Keys.CoreConfig.SegmentDataNotEnough)); + } + var endPos = ReaderIndex + segmentSize; Bytes[ReaderIndex] = Bytes[endPos]; Bytes[ReaderIndex + 1] = Bytes[endPos + 1]; @@ -1894,6 +1912,15 @@ public void ReadSegment(out int startIndex, out int segmentSize) { EnsureRead(3); segmentSize = ((h & 0x0f) << 24) | ((int)Bytes[ReaderIndex] << 16) | ((int)Bytes[ReaderIndex + 1] << 8) | Bytes[ReaderIndex + 2]; + + // 段长来自数据本身:先校验可读范围再搬运尾部字节(long 算术防 int 溢出出负), + // 损坏数据统一抛 SerializationException 而非越界异常 + // Localization: CoreConfig.ByteBuf.SegmentDataNotEnough - 段数据不足 + if ((long)ReaderIndex + segmentSize + 3 > WriterIndex) + { + throw new SerializationException(LocalizationService.GetString(Localization.Keys.CoreConfig.SegmentDataNotEnough)); + } + var endPos = ReaderIndex + segmentSize; Bytes[ReaderIndex] = Bytes[endPos]; Bytes[ReaderIndex + 1] = Bytes[endPos + 1]; @@ -1906,6 +1933,7 @@ public void ReadSegment(out int startIndex, out int segmentSize) throw new SerializationException(LocalizationService.GetString(Localization.Keys.CoreConfig.ExceedMaxSize)); } + // 前置校验后此断言理论上不再触发,保留兜底(单字节段分支仍依赖它) if (ReaderIndex > WriterIndex) { // Localization: CoreConfig.ByteBuf.SegmentDataNotEnough - 段数据不足 diff --git a/GameFrameX.Core/Config/StringUtil.cs b/GameFrameX.Core/Config/StringUtil.cs index d7c6e090b..15341fb1c 100644 --- a/GameFrameX.Core/Config/StringUtil.cs +++ b/GameFrameX.Core/Config/StringUtil.cs @@ -40,10 +40,10 @@ namespace GameFrameX.Core.Config; public static class StringUtil { /// - /// 将对象转换为字符串表示形式。 + /// 将对象转换为字符串表示形式。调试辅助方法:反射遍历全部字段与属性,避免用于热路径。 /// /// - /// Converts an object to its string representation. + /// Converts an object to its string representation. Debug helper: reflects over all fields and properties; avoid hot paths. /// /// 要转换的对象 / The object to convert /// 对象的字符串表示形式 / The string representation of the object @@ -53,10 +53,12 @@ public static string ToStr(object o) } /// - /// 将对象转换为字符串表示形式,并使用提供的StringBuilder进行构建。 + /// 将对象转换为字符串表示形式,并使用提供的StringBuilder进行构建。调试辅助方法:反射遍历全部字段与属性,避免用于热路径。 /// /// - /// Converts an object to its string representation using the provided StringBuilder. + /// Converts an object to its string representation using the provided StringBuilder. Debug helper: reflects over all fields and properties; avoid hot paths. + /// 无 getter 或带参数的索引器属性会被跳过,不抛异常。 + /// Properties without a getter or indexer properties are skipped instead of throwing. /// /// 要转换的对象 / The object to convert /// 用于构建字符串的StringBuilder / The StringBuilder used to build the string @@ -70,6 +72,12 @@ public static string ToStr(object o, StringBuilder sb) foreach (var p in o.GetType().GetProperties()) { + // 无 getter 或索引器属性不可无参取值,跳过而非抛异常 + if (!p.CanRead || p.GetIndexParameters().Length > 0) + { + continue; + } + sb.Append($"{p.Name} = {p.GetValue(o)},"); } diff --git a/Tests/GameFrameX.Tests/Config/ByteBufReadSegmentTests.cs b/Tests/GameFrameX.Tests/Config/ByteBufReadSegmentTests.cs new file mode 100644 index 000000000..4c3bae5c3 --- /dev/null +++ b/Tests/GameFrameX.Tests/Config/ByteBufReadSegmentTests.cs @@ -0,0 +1,91 @@ +// ========================================================================================== +// 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 refer to 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.Core.Config; +using Xunit; + +namespace GameFrameX.Tests.Config; + +/// +/// ReadSegment 损坏段长回归单测(GFX-1178 / CR-M08): +/// 段长来自数据本身,三个多字节头分支必须在索引访问前完成可读范围校验—— +/// 声明超大段但数据截断时抛 , +/// 而非旧实现先以越界 endPos 索引抛 。 +/// +public class ByteBufReadSegmentTests +{ + [Fact] + public void ReadSegment_TruncatedTwoByteHeader_ThrowsSerializationException() + { + // h = 0x81 → 两字节头分支;声明段长 (1 << 8) | 0xFF = 511,实际可读仅 2 字节 + var buffer = new ByteBuf(new byte[] { 0x81, 0xFF }); + + var exception = Assert.Throws(() => buffer.ReadSegment(out _, out _)); + Assert.NotNull(exception.Message); + } + + [Fact] + public void ReadSegment_TruncatedThreeByteHeader_ThrowsSerializationException() + { + // h = 0xC1 → 三字节头分支;声明段长 (1 << 16) | 0xFFFF = 131071,实际可读仅 3 字节 + var buffer = new ByteBuf(new byte[] { 0xC1, 0xFF, 0xFF }); + + Assert.Throws(() => buffer.ReadSegment(out _, out _)); + } + + [Fact] + public void ReadSegment_TruncatedFourByteHeader_ThrowsSerializationException() + { + // h = 0xE1 → 四字节头分支;声明段长 (1 << 24) | 0xFFFFFF,实际可读仅 4 字节 + var buffer = new ByteBuf(new byte[] { 0xE1, 0xFF, 0xFF, 0xFF }); + + Assert.Throws(() => buffer.ReadSegment(out _, out _)); + } + + [Fact] + public void ReadSegment_TruncatedSingleByteSegment_ThrowsSerializationException() + { + // h = 0x05 → 单字节段分支(无额外索引访问),依赖尾部 ReaderIndex 断言 + var buffer = new ByteBuf(new byte[] { 0x05 }); + + Assert.Throws(() => buffer.ReadSegment(out _, out _)); + } + + [Fact] + public void ReadSegment_ExactFit_DoesNotThrow() + { + // h = 0x80, 段长 1:头(1) + 段数据(1) + 尾部搬运字节(1) 恰好填满 3 字节可读区 + var buffer = new ByteBuf(new byte[] { 0x80, 0x01, 0x2A }); + + buffer.ReadSegment(out var startIndex, out var segmentSize); + + Assert.Equal(1, segmentSize); + Assert.Equal(1, startIndex); + } +} diff --git a/Tests/GameFrameX.Tests/Config/StringUtilToStrTests.cs b/Tests/GameFrameX.Tests/Config/StringUtilToStrTests.cs new file mode 100644 index 000000000..a695ec376 --- /dev/null +++ b/Tests/GameFrameX.Tests/Config/StringUtilToStrTests.cs @@ -0,0 +1,84 @@ +// ========================================================================================== +// 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 refer to 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 System.Text; +using GameFrameX.Core.Config; +using Xunit; + +namespace GameFrameX.Tests.Config; + +/// +/// StringUtil.ToStr 属性守卫回归单测(GFX-1178 / CR-L03): +/// 无 getter 属性与索引器属性必须被跳过——旧实现对其调用 GetValue 抛 ArgumentException。 +/// +public class StringUtilToStrTests +{ + private sealed class ProbeObject + { +#pragma warning disable CS0169 // 未读取字段:本探针字段仅用于 ToStr 输出断言 + private readonly int _hiddenField = 7; +#pragma warning restore CS0169 + + public int ReadableField = 42; + + public int ReadableProperty => 99; + + public int WriteOnlyProperty + { + set { _ = value; } + } + + public int this[int index] => index; + } + + [Fact] + public void ToStr_ObjectWithIndexerAndWriteOnlyProperty_DoesNotThrow() + { + var result = StringUtil.ToStr(new ProbeObject(), new StringBuilder()); + + Assert.NotNull(result); + } + + [Fact] + public void ToStr_IncludesReadableMembers() + { + var result = StringUtil.ToStr(new ProbeObject()); + + Assert.Contains("ReadableField = 42", result); + Assert.Contains("ReadableProperty = 99", result); + } + + [Fact] + public void ToStr_EmptyObject_ReturnsEmptyContent() + { + var result = StringUtil.ToStr(new object()); + + Assert.Equal(string.Empty, result); + } +} diff --git a/Tests/GameFrameX.Tests/Core/BaseDataTableCacheInvalidationTests.cs b/Tests/GameFrameX.Tests/Core/BaseDataTableCacheInvalidationTests.cs index ae55c5406..725ed989b 100644 --- a/Tests/GameFrameX.Tests/Core/BaseDataTableCacheInvalidationTests.cs +++ b/Tests/GameFrameX.Tests/Core/BaseDataTableCacheInvalidationTests.cs @@ -67,4 +67,104 @@ public async Task Reload_Refreshes_First_Last_Count_Caches() Assert.Equal(20L, table.FirstOrDefault); Assert.Equal(24L, table.LastOrDefault); } + + /// + /// 混合键测试表:DataList 登记全部条目,两键字典各只登记一部分—— + /// 用于锁定 Count 的事实源必须是条目列表而非两字典计数取 Max(GFX-1178 / CR-L02)。 + /// + private class MixedKeyTestTable : BaseDataTable + { + public override Task LoadAsync() + { + LongDataMaps.Clear(); + StringDataMaps.Clear(); + DataList.Clear(); + + for (var i = 1; i <= 5; i++) + { + DataList.Add((long)i); + } + + // 两键字典各只登记部分键型(2 + 3 = 5 条,但任一单字典都不足条目数) + LongDataMaps.Add(1, 1L); + LongDataMaps.Add(2, 2L); + StringDataMaps.Add("3", 3L); + StringDataMaps.Add("4", 4L); + StringDataMaps.Add("5", 5L); + + InvalidateCaches(); + return Task.CompletedTask; + } + } + + [Fact] + public async Task Count_MixedKeyDictionaries_MatchesDataListCount() + { + var table = new MixedKeyTestTable(); + await table.LoadAsync(); + + // 旧实现取 Math.Max(LongDataMaps.Count, StringDataMaps.Count) = 3,低估条目数 + Assert.Equal(5, table.Count); + } + + /// + /// 长整型值测试表:可配置条目数(含 0),用于 Max/Min 空表语义回归(GFX-1178 / CR-L02)。 + /// + private class LongValueTestTable : BaseDataTable + { + private int _count; + + public void Configure(int count) + { + _count = count; + } + + public override Task LoadAsync() + { + LongDataMaps.Clear(); + StringDataMaps.Clear(); + DataList.Clear(); + for (var i = 0; i < _count; i++) + { + var value = 10L + i; + LongDataMaps.Add(value, value); + DataList.Add(value); + } + + InvalidateCaches(); + return Task.CompletedTask; + } + } + + [Fact] + public async Task Max_EmptyTable_ReturnsDefaultWithoutThrowing() + { + var table = new LongValueTestTable(); + table.Configure(0); + await table.LoadAsync(); + + // 旧实现空表直接抛 InvalidOperationException + Assert.Null(table.Max(item => item)); + } + + [Fact] + public async Task Min_EmptyTable_ReturnsDefaultWithoutThrowing() + { + var table = new LongValueTestTable(); + table.Configure(0); + await table.LoadAsync(); + + Assert.Null(table.Min(item => item)); + } + + [Fact] + public async Task Max_Min_NonEmptyTable_ReturnProjection() + { + var table = new LongValueTestTable(); + table.Configure(3); + await table.LoadAsync(); + + Assert.Equal(12L, (long)table.Max(item => item)); + Assert.Equal(10L, (long)table.Min(item => item)); + } } diff --git a/Tests/GameFrameX.Tests/Core/TimeOutAttributeTests.cs b/Tests/GameFrameX.Tests/Core/TimeOutAttributeTests.cs new file mode 100644 index 000000000..0cb9c63b3 --- /dev/null +++ b/Tests/GameFrameX.Tests/Core/TimeOutAttributeTests.cs @@ -0,0 +1,65 @@ +// ========================================================================================== +// 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 refer to 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 System; +using GameFrameX.Core.Abstractions.Attribute; +using Xunit; + +namespace GameFrameX.Tests.Core; + +/// +/// TimeOutAttribute(TimeSpan) 总毫秒语义回归单测(GFX-1178 / CR-L04): +/// 旧实现取 TimeSpan.Milliseconds(0-999 分量),30 秒被截断为 0、1 分 30 秒错成 30000。 +/// +public class TimeOutAttributeTests +{ + [Fact] + public void TimeSpan30Seconds_MapsTo30000Milliseconds() + { + var attribute = new TimeOutAttribute(TimeSpan.FromSeconds(30)); + + Assert.Equal(30000, attribute.TimeOut); + } + + [Fact] + public void TimeSpan90Seconds_MapsTo90000Milliseconds() + { + var attribute = new TimeOutAttribute(new TimeSpan(0, 1, 30)); + + Assert.Equal(90000, attribute.TimeOut); + } + + [Fact] + public void IntOverload_Unchanged() + { + var attribute = new TimeOutAttribute(12000); + + Assert.Equal(12000, attribute.TimeOut); + } +} From 67ec963575fe3c87d10e1dfce9d67077864c4850 Mon Sep 17 00:00:00 2001 From: Blank Date: Sat, 31 Oct 2026 21:32:02 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(core):=20=E4=BA=8B=E4=BB=B6=E6=B4=BE?= =?UTF-8?q?=E5=8F=91=E5=91=8A=E8=AD=A6=E5=8F=82=E6=95=B0=E4=B8=8E=E5=9B=9E?= =?UTF-8?q?=E6=94=B6=E5=9B=9E=E8=B0=83=E7=BA=BF=E7=A8=8B=E5=AE=89=E5=85=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按审查 CR-L01/L09/L10/L11:DispatchNoListeners 第二参数改传 Actor 类型(全局路径经 ActorIdGenerator 反解);组件代理未命中 就地记告警并跳过,不再以 null 代理调用监听器;回收回调集合改 ConcurrentQueue,并发注册与排空无未定义行为;删除不可达的 catch(RpcException) 分支(抽象类全仓无子类)。 Linear: GFX-1178 --- GameFrameX.Core/Actors/Actor.cs | 16 +- .../RPC/BaseRpcComponentHandler.cs | 7 - GameFrameX.Core/Events/EventDispatcher.cs | 16 +- .../ActorRecycleCallbackConcurrencyTests.cs | 162 ++++++++++++++++++ 4 files changed, 180 insertions(+), 21 deletions(-) create mode 100644 Tests/GameFrameX.Hotfix.Tests/ActorRecycleCallbackConcurrencyTests.cs diff --git a/GameFrameX.Core/Actors/Actor.cs b/GameFrameX.Core/Actors/Actor.cs index afa827d25..b10be3daa 100644 --- a/GameFrameX.Core/Actors/Actor.cs +++ b/GameFrameX.Core/Actors/Actor.cs @@ -323,13 +323,13 @@ public Task OnRecycleAsync() { try { - while (_onceRecycleCallbacks.First != null) + // ConcurrentQueue 排空:注册(AddOnceRecycleCallback)可在任意线程并发进行, + // 取出即执行,避免 LinkedList 并发读写的未定义行为 + while (_onceRecycleCallbacks.TryDequeue(out var callback)) { - var first = _onceRecycleCallbacks.First; - _onceRecycleCallbacks.RemoveFirst(); try { - first.Value?.Invoke(); + callback?.Invoke(); } catch (Exception ex) { @@ -345,15 +345,11 @@ public Task OnRecycleAsync() // Localization: Core.Actor.RecycleFailed - Actor回收过程异常 actorId:{0} actorType:{1} 异常:\n{2} LogHelper.Error(ex, LocalizationService.GetString(Localization.Keys.Core.Actor.RecycleFailed, Id, Type, ex)); } - finally - { - _onceRecycleCallbacks.Clear(); - } return Task.CompletedTask; } - private readonly LinkedList _onceRecycleCallbacks = new LinkedList(); + private readonly ConcurrentQueue _onceRecycleCallbacks = new ConcurrentQueue(); /// /// 添加一个在Actor回收时执行一次的回调事件 @@ -368,7 +364,7 @@ public Task OnRecycleAsync() /// public void AddOnceRecycleCallback(Action action) { - _onceRecycleCallbacks.AddLast(action); + _onceRecycleCallbacks.Enqueue(action); } /// diff --git a/GameFrameX.Core/BaseHandler/RPC/BaseRpcComponentHandler.cs b/GameFrameX.Core/BaseHandler/RPC/BaseRpcComponentHandler.cs index e0e905d05..a978efb5c 100644 --- a/GameFrameX.Core/BaseHandler/RPC/BaseRpcComponentHandler.cs +++ b/GameFrameX.Core/BaseHandler/RPC/BaseRpcComponentHandler.cs @@ -150,13 +150,6 @@ private async Task ExecuteWorkAsync(CancellationToken cancellationToken) await InnerActionCoreAsync(RequestMessage, response, cancellationToken); response?.SetUniqueId(requestId); } - catch (RpcException e) - { - if (response != null) - { - response.ErrorCode = e.ErrorCode; - } - } catch (Exception e) { // Localization: Core.MessageHandler.InnerActionError - 内部操作错误: {0} {1} diff --git a/GameFrameX.Core/Events/EventDispatcher.cs b/GameFrameX.Core/Events/EventDispatcher.cs index bfb7051cf..55baa2c53 100644 --- a/GameFrameX.Core/Events/EventDispatcher.cs +++ b/GameFrameX.Core/Events/EventDispatcher.cs @@ -142,8 +142,8 @@ internal static async Task DispatchToActorAsync(IActor actor, long actorId, Game var listeners = HotfixManager.Resolver.FindListeners(actor.Type, eventArgs.GetType()); if (listeners.IsNullOrEmpty()) { - // Localization: Core.EventDispatcher.DispatchNoListeners - EventDispatcher.Dispatch Actor {0} {1} 事件类型:{2} 没有找到任何监听者 - LogHelper.Warning(LocalizationService.GetString(Localization.Keys.Core.EventDispatcher.DispatchNoListeners, actorId, eventArgs.GetType().Name, eventArgs.GetType().Name)); + // Localization: Core.EventDispatcher.DispatchNoListeners - EventDispatcher.Dispatch Actor {0} 类型:{1} 事件类型:{2} 没有找到任何监听者 + LogHelper.Warning(LocalizationService.GetString(Localization.Keys.Core.EventDispatcher.DispatchNoListeners, actorId, actor.Type, eventArgs.GetType().Name)); return; } @@ -167,8 +167,8 @@ private static void DispatchGlobal(long actorId, GameEventArgs eventArgs) var listeners = HotfixManager.Resolver.FindListeners(eventArgs.GetType()); if (listeners.IsNullOrEmpty()) { - // Localization: Core.EventDispatcher.DispatchNoListeners - EventDispatcher.Dispatch Actor {0} {1} 事件类型:{2} 没有找到任何监听者 - LogHelper.Warning(LocalizationService.GetString(Localization.Keys.Core.EventDispatcher.DispatchNoListeners, actorId, eventArgs.GetType().Name, eventArgs.GetType().Name)); + // Localization: Core.EventDispatcher.DispatchNoListeners - EventDispatcher.Dispatch Actor {0} 类型:{1} 事件类型:{2} 没有找到任何监听者 + LogHelper.Warning(LocalizationService.GetString(Localization.Keys.Core.EventDispatcher.DispatchNoListeners, actorId, ActorIdGenerator.GetActorType(actorId), eventArgs.GetType().Name)); return; } @@ -222,6 +222,14 @@ private static async Task DispatchListenerInActorAsync(IActor actor, IEventListe { // 获取监听器对应的组件代理 var componentAgent = await actor.GetComponentAgentAsync(listener.AgentType, false); + + if (componentAgent == null) + { + // 组件未注册到该 Actor(isNew:false 未命中):显式跳过并告警,绝不以 null 代理调用监听器 + // Localization: Core.EventDispatcher.DispatchOwnerActorNotLoaded - EventDispatcher.Dispatch Actor {0} 事件类型:{1} 监听器 {2} 所属 Actor 不在内存中,已跳过 + LogHelper.Warning(LocalizationService.GetString(Localization.Keys.Core.EventDispatcher.DispatchOwnerActorNotLoaded, actorId, eventArgs.GetType().Name, listener.GetType().FullName)); + return; + } try { // 调用监听器的事件处理方法 diff --git a/Tests/GameFrameX.Hotfix.Tests/ActorRecycleCallbackConcurrencyTests.cs b/Tests/GameFrameX.Hotfix.Tests/ActorRecycleCallbackConcurrencyTests.cs new file mode 100644 index 000000000..b00e9c54b --- /dev/null +++ b/Tests/GameFrameX.Hotfix.Tests/ActorRecycleCallbackConcurrencyTests.cs @@ -0,0 +1,162 @@ +// ========================================================================================== +// 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 refer to 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.Actors; +using GameFrameX.Core.Components; +using GameFrameX.Foundation.Localization.Core; +using GameFrameX.Foundation.Localization.Providers; +using GameFrameX.Utility; +using GameFrameX.Utility.Setting; +using Xunit; + +namespace GameFrameX.Hotfix.Tests; + +/// +/// Actor 一次性回收回调线程安全回归单测(GFX-1178 / CR-L09): +/// 是可在任意线程调用的公共 API, +/// 与 排空并发时必须无异常且每个回调恰执行一次—— +/// 旧实现的非线程安全 LinkedList 在并发注册/回收下是未定义行为。 +/// +/// +/// 沿 ActorManagerRemoveRecycleCallbackTests 先例挂静态状态串行 Collection(进程级共享状态)。 +/// +[Collection(HotfixManagerStaticStateCollection.Name)] +public sealed class ActorRecycleCallbackConcurrencyTests +{ + /// + /// 探针Actor类型:每服单例段(type ≥ 128)且未注册任何组件,回调行为只反映回调队列本身。 + /// + private const ushort ProbeActorType = 201; + + private static bool _environmentInitialized; + + public ActorRecycleCallbackConcurrencyTests() + { + if (GlobalSettings.CurrentSetting == null) + { + GlobalSettings.SetCurrentSetting(new AppSetting { ServerId = 1 }); + } + } + + private static async Task InitializeEnvironmentOnceAsync() + { + if (_environmentInitialized) + { + return; + } + + await ComponentRegister.InitAsync(typeof(PlayerComponent).Assembly); + LocalizationService.RegisterProvider(new AssemblyResourceProvider(typeof(GameFrameX.Localization.Keys.CoreExceptions.HotfixModule).Assembly)); + _environmentInitialized = true; + } + + [Fact] + public async Task RecycleCallback_RegisteredThenRecycled_ExecutesEachExactlyOnce() + { + await InitializeEnvironmentOnceAsync(); + var actor = new Actor(ActorIdGenerator.GetActorId(ProbeActorType, 9741), ProbeActorType); + + var executedCount = 0; + actor.AddOnceRecycleCallback(() => Interlocked.Increment(ref executedCount)); + actor.AddOnceRecycleCallback(() => Interlocked.Increment(ref executedCount)); + actor.AddOnceRecycleCallback(() => throw new InvalidOperationException("callback-boom")); + + await actor.OnRecycleAsync(); + await actor.OnRecycleAsync(); + + // 含一个抛异常回调:异常被隔离,其余回调恰执行一次,二次回收不重复执行 + Assert.Equal(2, executedCount); + } + + [Fact] + public async Task RecycleCallback_ConcurrentRegisterAndRecycle_NoExceptionAllExecutedOnce() + { + await InitializeEnvironmentOnceAsync(); + var actor = new Actor(ActorIdGenerator.GetActorId(ProbeActorType, 9742), ProbeActorType); + + const int producerCount = 8; + const int perProducerCount = 1250; + var totalCallbacks = producerCount * perProducerCount; + var executedCount = 0; + + var producers = Enumerable.Range(0, producerCount).Select(_ => Task.Run(() => + { + for (var i = 0; i < perProducerCount; i++) + { + actor.AddOnceRecycleCallback(() => Interlocked.Increment(ref executedCount)); + } + })).ToArray(); + + await Task.WhenAll(producers); + + // 生产者全部入队后排空:每个回调恰执行一次 + await actor.OnRecycleAsync(); + Assert.Equal(totalCallbacks, executedCount); + + await actor.OnRecycleAsync(); + Assert.Equal(totalCallbacks, executedCount); + } + + [Fact] + public async Task RecycleCallback_DrainRacingWithProducers_AllExecutedExactlyOnce() + { + await InitializeEnvironmentOnceAsync(); + var actor = new Actor(ActorIdGenerator.GetActorId(ProbeActorType, 9743), ProbeActorType); + + const int producerCount = 4; + const int perProducerCount = 2500; + var totalCallbacks = producerCount * perProducerCount; + var executedCount = 0; + + var producers = Enumerable.Range(0, producerCount).Select(_ => Task.Run(async () => + { + for (var i = 0; i < perProducerCount; i++) + { + actor.AddOnceRecycleCallback(() => Interlocked.Increment(ref executedCount)); + if (i % 500 == 0) + { + await Task.Yield(); + } + } + })).ToArray(); + + // 排空与注册并发进行:ConcurrentQueue 下取出即执行,回调不丢不重 + while (executedCount < totalCallbacks || producers.Any(task => !task.IsCompleted)) + { + await actor.OnRecycleAsync(); + await Task.Yield(); + } + + await Task.WhenAll(producers); + await actor.OnRecycleAsync(); + + Assert.Equal(totalCallbacks, executedCount); + } +} From 320f0931aede8f2ac31c90ef0dddc22d3d1e9966 Mon Sep 17 00:00:00 2001 From: Blank Date: Sat, 31 Oct 2026 21:32:06 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(core):=20=E9=98=9F=E5=88=97=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E4=B8=8E=E8=B7=AF=E7=94=B1=E5=90=8C=E6=AD=A5=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E6=9C=AC=E5=9C=B0=E5=8C=96=E6=94=B6=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按审查 CR-L05/L06/L07/L08:新增 8 个本地化键,替换 WorkerActor 队列失败路径、热更版本字符校验与会话路由同步钩子的硬编码日志, DispatchNoListeners 文案补「类型」语义;GlobalTimer.StopAsync 停机消息改走 LogHelper 日志管线;GetByPlayerId 删除 HasActor 死分支;GetListHttpHandler 改 HashSet 线性去重;GetInstance 未知类型抛带类型名的 InvalidOperationException。 Linear: GFX-1178 --- GameFrameX.Core/Actors/Impl/WorkerActor.cs | 18 ++- GameFrameX.Core/Hotfix/HotfixLoadOptions.cs | 5 +- GameFrameX.Core/Hotfix/HotfixModule.cs | 24 +++- .../Session/PlayerSessionManager.cs | 16 +-- GameFrameX.Core/Timer/GlobalTimer.cs | 7 +- GameFrameX.Localization/Keys.Core.cs | 80 ++++++++++- .../Localization/Messages/Resources.resx | 30 +++- .../Messages/Resources.zh-CN.resx | 30 +++- .../HotfixModuleGetInstanceTests.cs | 105 ++++++++++++++ .../PlayerSessionManagerGetByPlayerIdTests.cs | 133 ++++++++++++++++++ 10 files changed, 421 insertions(+), 27 deletions(-) create mode 100644 Tests/GameFrameX.Hotfix.Tests/HotfixModuleGetInstanceTests.cs create mode 100644 Tests/GameFrameX.Tests/Core/Session/PlayerSessionManagerGetByPlayerIdTests.cs diff --git a/GameFrameX.Core/Actors/Impl/WorkerActor.cs b/GameFrameX.Core/Actors/Impl/WorkerActor.cs index 0c607e3c1..a2ed9075f 100644 --- a/GameFrameX.Core/Actors/Impl/WorkerActor.cs +++ b/GameFrameX.Core/Actors/Impl/WorkerActor.cs @@ -218,7 +218,8 @@ private async Task InnerRunAsync(WorkWrapper wrapper) private static InvalidOperationException CreateQueueRejectedException(WorkWrapper wrapper) { - return new InvalidOperationException($"WorkerActor queue rejected work. actorId: {wrapper.Owner?.Id}, trace: {GetTraceSafely(wrapper)}"); + // Localization: CoreExceptions.WorkerActor.QueueRejected - WorkerActor 队列拒绝了工作单元。actorId:{0} trace:{1} + return new InvalidOperationException(LocalizationService.GetString(Localization.Keys.CoreExceptions.WorkerActor.QueueRejected, wrapper.Owner?.Id, GetTraceSafely(wrapper))); } private static string GetTraceSafely(WorkWrapper wrapper) @@ -229,7 +230,8 @@ private static string GetTraceSafely(WorkWrapper wrapper) } catch (Exception exception) { - return $"trace unavailable: {exception.Message}"; + // Localization: CoreExceptions.WorkerActor.TraceUnavailable - trace不可用:{0} + return LocalizationService.GetString(Localization.Keys.CoreExceptions.WorkerActor.TraceUnavailable, exception.Message); } } @@ -284,7 +286,8 @@ private static void CompleteSendResult(Task sendTask, WorkWrapper wrapper, if (sendTask.IsFaulted) { - CompleteFailedPost(wrapper, sendTask.Exception?.GetBaseException() ?? new InvalidOperationException("WorkerActor queue post failed."), completeOnFailure); + // Localization: CoreExceptions.WorkerActor.QueueFailed - WorkerActor 队列投递失败。actorId:{0} trace:{1} + CompleteFailedPost(wrapper, sendTask.Exception?.GetBaseException() ?? new InvalidOperationException(LocalizationService.GetString(Localization.Keys.CoreExceptions.WorkerActor.QueueFailed, wrapper.Owner?.Id, GetTraceSafely(wrapper))), completeOnFailure); return; } @@ -303,7 +306,8 @@ private static void CompleteRejectedPost(WorkWrapper wrapper, bool completeOnFai return; } - Log.Warning(exception, "WorkerActor queue rejected fire-and-forget work"); + // Localization: CoreExceptions.WorkerActor.QueueRejected - WorkerActor 队列拒绝了工作单元。actorId:{0} trace:{1} + Log.Warning(exception, LocalizationService.GetString(Localization.Keys.CoreExceptions.WorkerActor.QueueRejected, wrapper.Owner?.Id, GetTraceSafely(wrapper))); wrapper.ForceSetResult(); } @@ -315,7 +319,8 @@ private static void CompleteCanceledPost(WorkWrapper wrapper, CancellationToken return; } - Log.Warning("WorkerActor queue post was cancelled. actorId: {ActorId}, trace: {Trace}", wrapper.Owner?.Id, GetTraceSafely(wrapper)); + // Localization: CoreExceptions.WorkerActor.QueueCancelled - WorkerActor 队列投递被取消。actorId:{0} trace:{1} + Log.Warning(LocalizationService.GetString(Localization.Keys.CoreExceptions.WorkerActor.QueueCancelled, wrapper.Owner?.Id, GetTraceSafely(wrapper))); wrapper.ForceSetResult(); } @@ -327,7 +332,8 @@ private static void CompleteFailedPost(WorkWrapper wrapper, Exception exception, return; } - Log.Warning(exception, "WorkerActor queue post failed. actorId: {ActorId}, trace: {Trace}", wrapper.Owner?.Id, GetTraceSafely(wrapper)); + // Localization: CoreExceptions.WorkerActor.QueueFailed - WorkerActor 队列投递失败。actorId:{0} trace:{1} + Log.Warning(exception, LocalizationService.GetString(Localization.Keys.CoreExceptions.WorkerActor.QueueFailed, wrapper.Owner?.Id, GetTraceSafely(wrapper))); wrapper.ForceSetResult(); } diff --git a/GameFrameX.Core/Hotfix/HotfixLoadOptions.cs b/GameFrameX.Core/Hotfix/HotfixLoadOptions.cs index 08bce5849..3717709ac 100644 --- a/GameFrameX.Core/Hotfix/HotfixLoadOptions.cs +++ b/GameFrameX.Core/Hotfix/HotfixLoadOptions.cs @@ -27,6 +27,8 @@ // Official Documentation: https://gameframex.doc.alianblank.com/ // ========================================================================================== +using GameFrameX.Foundation.Localization.Core; + namespace GameFrameX.Core.Hotfix; /// @@ -103,7 +105,8 @@ public string ResolveAssemblyFilePath() // The version flows in from HTTP rollback traffic: reject separators and ".." so the load path cannot be escaped if (Version.IndexOfAny(ForbiddenVersionChars) >= 0 || Version.Contains("..", StringComparison.Ordinal)) { - throw new ArgumentException($"Version 含非法路径字符: {Version} / Version contains forbidden path characters: {Version}", nameof(Version)); + // Localization: CoreExceptions.Hotfix.InvalidVersionChars - Version 含非法路径字符:{0} + throw new ArgumentException(LocalizationService.GetString(Localization.Keys.CoreExceptions.HotfixModule.InvalidVersionChars, Version), nameof(Version)); } directory = Version; diff --git a/GameFrameX.Core/Hotfix/HotfixModule.cs b/GameFrameX.Core/Hotfix/HotfixModule.cs index 1b64068a4..5df6182fb 100644 --- a/GameFrameX.Core/Hotfix/HotfixModule.cs +++ b/GameFrameX.Core/Hotfix/HotfixModule.cs @@ -616,15 +616,15 @@ internal BaseHttpHandler GetHttpHandler(string cmd) internal List GetListHttpHandler() { var values = _httpHandlerMap.Values; + var seenHandlers = new HashSet(); var list = new List(values.Count / 2); foreach (var handler in values) { - if (list.Contains(handler)) + // 同一处理器实例可注册多个 cmd,引用去重线性完成 + if (seenHandlers.Add(handler)) { - continue; + list.Add(handler); } - - list.Add(handler); } return list; @@ -693,7 +693,21 @@ internal List FindListeners(Type eventArgsType) /// 实例对象。 internal T GetInstance(string typeName) { - return (T)_typeCacheMap.GetOrAdd(typeName, k => HotfixAssembly.CreateInstance(k)); + if (!_typeCacheMap.TryGetValue(typeName, out var value)) + { + value = HotfixAssembly.CreateInstance(typeName); + if (value == null) + { + // ConcurrentDictionary 不接受 null 值,工厂返回 null 会抛脱离语境的 ArgumentNullException; + // 此处显式抛带类型名的契约异常 + // Localization: CoreExceptions.Hotfix.TypeNotFound - 未找到类型:{0} + throw new InvalidOperationException(LocalizationService.GetString(Localization.Keys.CoreExceptions.HotfixModule.TypeNotFound, typeName)); + } + + _typeCacheMap[typeName] = value; + } + + return (T)value; } /// diff --git a/GameFrameX.Core/Session/PlayerSessionManager.cs b/GameFrameX.Core/Session/PlayerSessionManager.cs index ae15dc33c..32f04ead3 100644 --- a/GameFrameX.Core/Session/PlayerSessionManager.cs +++ b/GameFrameX.Core/Session/PlayerSessionManager.cs @@ -35,6 +35,8 @@ using GameFrameX.Foundation.Logger; using GameFrameX.Network.Abstractions; using GameFrameX.Utility.Setting; +using GameFrameX.Foundation.Localization.Core; + namespace GameFrameX.Core.Session; @@ -153,13 +155,7 @@ public void KickOffLineByPlayerId(long playerId) /// 对应的会话对象,如果不存在则返回null。 public IPlayerSession GetByPlayerId(long playerId) { - var roleSession = Get(m => m.PlayerId == playerId); - if (roleSession != null && ActorManager.HasActor(roleSession.PlayerId)) - { - return roleSession; - } - - return roleSession; + return Get(m => m.PlayerId == playerId); } /// @@ -487,7 +483,8 @@ private static async Task SyncUpsertSafeAsync(IPlayerRouteSyncTarget syncTarget, } catch (Exception exception) { - LogHelper.Warning(exception, "[PlayerSessionManager] player_route upsert hook failed for player {playerId}; the local PlayerRouteMap stays authoritative and the next SetOnline converges", playerId); + // Localization: Core.Session.RouteSyncUpsertFailed - [PlayerSessionManager] 玩家路由 upsert 同步钩子失败,玩家:{0};本地 PlayerRouteMap 仍为事实源,下次 SetOnline 会重新收敛 + LogHelper.Warning(exception, LocalizationService.GetString(Localization.Keys.Core.Session.RouteSyncUpsertFailed, playerId)); } } @@ -521,7 +518,8 @@ private static async Task SyncDeleteSafeAsync(IPlayerRouteSyncTarget syncTarget, } catch (Exception exception) { - LogHelper.Warning(exception, "[PlayerSessionManager] player_route delete hook failed for player {playerId}", playerId); + // Localization: Core.Session.RouteSyncDeleteFailed - [PlayerSessionManager] 玩家路由 delete 同步钩子失败,玩家:{0} + LogHelper.Warning(exception, LocalizationService.GetString(Localization.Keys.Core.Session.RouteSyncDeleteFailed, playerId)); } } } \ No newline at end of file diff --git a/GameFrameX.Core/Timer/GlobalTimer.cs b/GameFrameX.Core/Timer/GlobalTimer.cs index fd936ee7e..dd04bf248 100644 --- a/GameFrameX.Core/Timer/GlobalTimer.cs +++ b/GameFrameX.Core/Timer/GlobalTimer.cs @@ -138,7 +138,9 @@ private static long NextSaveTime() /// public static async Task StopAsync() { - Console.WriteLine(LocalizationService.GetString(Localization.Keys.Core.Timer.GlobalTimerStopStart)); + // Localization: Core.Timer.GlobalTimerStopStart - 全局定时器开始停止 + LogHelper.Info(LocalizationService.GetString(Localization.Keys.Core.Timer.GlobalTimerStopStart)); + IsWorking = false; if (_loopTask != null) { @@ -147,6 +149,7 @@ public static async Task StopAsync() await StateComponent.SaveAllAsync(true); await GameDb.CloseAsync(); - Console.WriteLine(LocalizationService.GetString(Localization.Keys.Core.Timer.GlobalTimerStopComplete)); + // Localization: Core.Timer.GlobalTimerStopComplete - 全局定时器停止完成 + LogHelper.Info(LocalizationService.GetString(Localization.Keys.Core.Timer.GlobalTimerStopComplete)); } } \ No newline at end of file diff --git a/GameFrameX.Localization/Keys.Core.cs b/GameFrameX.Localization/Keys.Core.cs index e68853ec9..bd9f188e0 100644 --- a/GameFrameX.Localization/Keys.Core.cs +++ b/GameFrameX.Localization/Keys.Core.cs @@ -778,12 +778,12 @@ public static class HotfixModule public static class EventDispatcher { /// - /// EventDispatcher.Dispatch Actor {0} {1} 事件类型:{2} 没有找到任何监听者 + /// EventDispatcher.Dispatch Actor {0} 类型:{1} 事件类型:{2} 没有找到任何监听者 /// /// /// 键名: Core.EventDispatcher.DispatchNoListeners /// 用途: 事件分发未找到监听者时记录 - /// 参数: {0} - Actor ID, {1} - 事件参数类型, {2} - 事件参数类型 + /// 参数: {0} - Actor ID, {1} - Actor 类型, {2} - 事件参数类型 /// public const string DispatchNoListeners = "Core.EventDispatcher.DispatchNoListeners"; @@ -807,6 +807,32 @@ public static class EventDispatcher /// public const string DispatchOwnerActorNotLoaded = "Core.EventDispatcher.DispatchOwnerActorNotLoaded"; } + + /// + /// 会话管理相关资源键 + /// + public static class Session + { + /// + /// [PlayerSessionManager] 玩家路由 upsert 同步钩子失败,玩家:{0};本地 PlayerRouteMap 仍为事实源,下次 SetOnline 会重新收敛 + /// + /// + /// 键名: Core.Session.RouteSyncUpsertFailed + /// 用途: 会话路由同步 upsert 钩子(控制库)失败时记录 + /// 参数: {0} - 玩家 ID + /// + public const string RouteSyncUpsertFailed = "Core.Session.RouteSyncUpsertFailed"; + + /// + /// [PlayerSessionManager] 玩家路由 delete 同步钩子失败,玩家:{0} + /// + /// + /// 键名: Core.Session.RouteSyncDeleteFailed + /// 用途: 会话路由同步 delete 钩子(控制库)失败时记录 + /// 参数: {0} - 玩家 ID + /// + public const string RouteSyncDeleteFailed = "Core.Session.RouteSyncDeleteFailed"; + } } /// @@ -859,6 +885,36 @@ public static class WorkerActor /// 参数: {0} - 获取追踪信息时发生的异常消息 /// public const string TraceUnavailable = "CoreExceptions.WorkerActor.TraceUnavailable"; + + /// + /// WorkerActor 队列拒绝了工作单元。actorId:{0} trace:{1} + /// + /// + /// 键名: CoreExceptions.WorkerActor.QueueRejected + /// 用途: 队列拒绝工作单元(邮箱满/停止)时作为异常消息与告警日志 + /// 参数: {0} - Actor ID, {1} - 调用链追踪信息 + /// + public const string QueueRejected = "CoreExceptions.WorkerActor.QueueRejected"; + + /// + /// WorkerActor 队列投递被取消。actorId:{0} trace:{1} + /// + /// + /// 键名: CoreExceptions.WorkerActor.QueueCancelled + /// 用途: 队列投递工作单元被取消时记录 + /// 参数: {0} - Actor ID, {1} - 调用链追踪信息 + /// + public const string QueueCancelled = "CoreExceptions.WorkerActor.QueueCancelled"; + + /// + /// WorkerActor 队列投递失败。actorId:{0} trace:{1} + /// + /// + /// 键名: CoreExceptions.WorkerActor.QueueFailed + /// 用途: 队列投递工作单元抛出异常时作为后备异常消息与告警日志 + /// 参数: {0} - Actor ID, {1} - 调用链追踪信息 + /// + public const string QueueFailed = "CoreExceptions.WorkerActor.QueueFailed"; } /// /// 定时器相关异常 @@ -1069,6 +1125,26 @@ public static class HotfixModule /// 参数: {0} - 实现类型全名, {1} - 服务类型全名 /// public const string MissingParameterlessConstructor = "CoreExceptions.Hotfix.MissingParameterlessConstructor"; + + /// + /// Version 含非法路径字符:{0} + /// + /// + /// 键名: CoreExceptions.Hotfix.InvalidVersionChars + /// 用途: 热更版本号含路径分隔符或「..」段被拒绝装载时抛出异常 + /// 参数: {0} - 被拒绝的版本号 + /// + public const string InvalidVersionChars = "CoreExceptions.Hotfix.InvalidVersionChars"; + + /// + /// 未找到类型:{0} + /// + /// + /// 键名: CoreExceptions.Hotfix.TypeNotFound + /// 用途: 按类型全名在热更程序集创建实例未命中时抛出异常 + /// 参数: {0} - 类型全名 + /// + public const string TypeNotFound = "CoreExceptions.Hotfix.TypeNotFound"; } /// diff --git a/GameFrameX.Localization/Localization/Messages/Resources.resx b/GameFrameX.Localization/Localization/Messages/Resources.resx index a048fd266..bb9d5f377 100644 --- a/GameFrameX.Localization/Localization/Messages/Resources.resx +++ b/GameFrameX.Localization/Localization/Messages/Resources.resx @@ -548,6 +548,18 @@ trace unavailable: {0} + + WorkerActor queue rejected work. actorId:{0} trace:{1} + + + + WorkerActor queue post was cancelled. actorId:{0} trace:{1} + + + + WorkerActor queue post failed. actorId:{0} trace:{1} + + component:[{0}] the actor type is not bound @@ -603,6 +615,14 @@ {0} must be a non-abstract closed type implementing {1} with a public parameterless constructor + + Version contains forbidden path characters: {0} + + + + Type not found: {0} + + Invalid component type {0}, value should be greater than {1} and less than {2} and not equal to {3} @@ -1696,7 +1716,15 @@ - EventDispatcher.Dispatch Actor {0} {1} No listeners found for event type: {2} + EventDispatcher.Dispatch Actor {0} type: {1} no listeners found for event type: {2} + + + + [PlayerSessionManager] player_route upsert hook failed for player {0}; the local PlayerRouteMap stays authoritative and the next SetOnline converges + + + + [PlayerSessionManager] player_route delete hook failed for player {0} diff --git a/GameFrameX.Localization/Localization/Messages/Resources.zh-CN.resx b/GameFrameX.Localization/Localization/Messages/Resources.zh-CN.resx index f6ec046d9..191bdd128 100644 --- a/GameFrameX.Localization/Localization/Messages/Resources.zh-CN.resx +++ b/GameFrameX.Localization/Localization/Messages/Resources.zh-CN.resx @@ -566,6 +566,18 @@ trace不可用:{0} + + WorkerActor 队列拒绝了工作单元。actorId:{0} trace:{1} + + + + WorkerActor 队列投递被取消。actorId:{0} trace:{1} + + + + WorkerActor 队列投递失败。actorId:{0} trace:{1} + + 组件:[{0}] Actor类型未绑定 @@ -624,6 +636,14 @@ {0} 必须是实现 {1} 的非抽象封闭类型,且具有公共无参构造函数 + + Version 含非法路径字符:{0} + + + + 未找到类型:{0} + + 无效的组件类型 {0},值应大于{1}且小于{2}和不为{3} @@ -1690,7 +1710,15 @@ - EventDispatcher.Dispatch Actor {0} {1} 事件类型:{2} 没有找到任何监听者 + EventDispatcher.Dispatch Actor {0} 类型:{1} 事件类型:{2} 没有找到任何监听者 + + + + [PlayerSessionManager] 玩家路由 upsert 同步钩子失败,玩家:{0};本地 PlayerRouteMap 仍为事实源,下次 SetOnline 会重新收敛 + + + + [PlayerSessionManager] 玩家路由 delete 同步钩子失败,玩家:{0} diff --git a/Tests/GameFrameX.Hotfix.Tests/HotfixModuleGetInstanceTests.cs b/Tests/GameFrameX.Hotfix.Tests/HotfixModuleGetInstanceTests.cs new file mode 100644 index 000000000..2ee9f3406 --- /dev/null +++ b/Tests/GameFrameX.Hotfix.Tests/HotfixModuleGetInstanceTests.cs @@ -0,0 +1,105 @@ +// ========================================================================================== +// 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 refer to 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.Components; +using GameFrameX.Core.Hotfix; +using GameFrameX.Foundation.Localization.Core; +using GameFrameX.Foundation.Localization.Providers; +using GameFrameX.Hotfix.Logic.Player.Login; +using GameFrameX.Utility.Setting; +using Xunit; + +namespace GameFrameX.Hotfix.Tests; + +/// +/// HotfixModule 实例解析与 HTTP 处理器列表回归单测(GFX-1178 / CR-L07): +/// GetInstance 对未知类型名必须抛带类型上下文的 , +/// 而非旧实现经 ConcurrentDictionary.GetOrAdd 值工厂返回 null 抛出的脱离语境 ; +/// GetListHttpHandler 对同一处理器实例的多 cmd 注册必须按引用去重。 +/// +public sealed class HotfixModuleGetInstanceTests +{ + private static bool _environmentInitialized; + + private static async Task CreateModuleAsync() + { + if (!_environmentInitialized) + { + if (GlobalSettings.CurrentSetting == null) + { + GlobalSettings.SetCurrentSetting(new AppSetting { ServerId = 1 }); + } + + await ComponentRegister.InitAsync(typeof(PlayerComponent).Assembly); + LocalizationService.RegisterProvider(new AssemblyResourceProvider(typeof(GameFrameX.Localization.Keys.CoreExceptions.HotfixModule).Assembly)); + _environmentInitialized = true; + } + + return new HotfixModule(typeof(PlayerComponentAgent).Assembly); + } + + [Fact] + public async Task GetInstance_UnknownTypeName_ThrowsInvalidOperationExceptionWithTypeContext() + { + var module = await CreateModuleAsync(); + const string unknownTypeName = "GameFrameX.Hotfix.Tests.NoSuchTypeAnywhere"; + + var exception = Assert.Throws(() => module.GetInstance(unknownTypeName)); + + Assert.Contains(unknownTypeName, exception.Message); + } + + [Fact] + public async Task GetInstance_KnownTypeName_ReturnsInstanceAndCachesIt() + { + var module = await CreateModuleAsync(); + var typeName = typeof(PlayerComponentAgent).FullName; + + var first = module.GetInstance(typeName); + var second = module.GetInstance(typeName); + + Assert.NotNull(first); + Assert.IsType(first); + Assert.Same(first, second); + } + + [Fact] + public async Task GetListHttpHandler_DeduplicatesHandlerInstancesByReference() + { + var module = await CreateModuleAsync(); + + var list = module.GetListHttpHandler(); + + // 热更程序集内存在真实 HTTP 处理器(每个处理器按 OriginalCmd/StandardCmd 注册两条) + Assert.NotEmpty(list); + // 同一处理器实例不得出现两次(引用去重) + Assert.Equal(list.Count, list.Distinct().Count()); + } +} diff --git a/Tests/GameFrameX.Tests/Core/Session/PlayerSessionManagerGetByPlayerIdTests.cs b/Tests/GameFrameX.Tests/Core/Session/PlayerSessionManagerGetByPlayerIdTests.cs new file mode 100644 index 000000000..8b79d3ca4 --- /dev/null +++ b/Tests/GameFrameX.Tests/Core/Session/PlayerSessionManagerGetByPlayerIdTests.cs @@ -0,0 +1,133 @@ +// ========================================================================================== +// 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 refer to 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.Core.Session; +using GameFrameX.Network.Abstractions; +using Xunit; + +using GameFrameX.SuperSocket.Server.Abstractions.Session; + +namespace GameFrameX.Tests.Core.Session; + +/// +/// GetByPlayerId 现状语义锁定单测(GFX-1178 / CR-L05): +/// 删除死代码 HasActor 分支后,返回值语义必须是「按 playerId 查会话、不按 Actor 在内存过滤」—— +/// UpdateSessionAsync 的同会话幂等重绑依赖该行为。 +/// +public sealed class PlayerSessionManagerGetByPlayerIdTests +{ + private const long PlayerId = 931001; + + [Fact] + public async Task GetByPlayerId_SessionExistsWithoutActor_ReturnsSession() + { + var manager = PlayerSessionManager.Instance; + var session = new PlayerSession("c438-getbyplayerid", new FakeNetworkChannel()); + manager.Add(session); + await manager.UpdateSessionAsync("c438-getbyplayerid", PlayerId, "sign-c438", _ => Task.CompletedTask); + + // 该玩家没有任何 Actor 在内存:仍必须取回同一会话(现状语义) + var found = manager.GetByPlayerId(PlayerId); + + Assert.Same(session, found); + manager.Remove("c438-getbyplayerid"); + } + + [Fact] + public void GetByPlayerId_NoSession_ReturnsNull() + { + var manager = PlayerSessionManager.Instance; + + Assert.Null(manager.GetByPlayerId(long.MinValue + 438)); + } + + /// + /// 最小通道替身(沿 PlayerSessionManagerConcurrentLoginTests 同款形态)。 + /// + private sealed class FakeNetworkChannel : INetworkChannel + { + public void Dispose() + { + } + + public ulong SendBytesLength => 0; + + public ulong SendPacketLength => 0; + + public ulong ReceiveBytesLength => 0; + + public ulong ReceivePacketLength => 0; + + public IGameAppSession GameAppSession => null; + + public Task WriteAsync(INetworkMessage msg, int errorCode = 0) + { + return Task.CompletedTask; + } + + public void Close() + { + } + + public T GetData(string key) + { + return default; + } + + public void ClearData() + { + } + + public void RemoveData(string key) + { + } + + public long GetLastMessageTimeSecond(in DateTime utcTime) + { + return 0; + } + + public bool IsClosed() + { + return false; + } + + public void SetData(string key, object value) + { + } + + public void UpdateReceivePacketBytesLength(ulong bufferLength) + { + } + + public void UpdateReceiveMessageTime(long offsetTicks = 0) + { + } + } +}