From 1753778caead80e52d301506365ace8acc9a04bf Mon Sep 17 00:00:00 2001 From: gewuyou <95328647+GeWuYou@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:50:44 +0800 Subject: [PATCH] =?UTF-8?q?fix(game):=20=E4=BF=AE=E5=A4=8D=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E5=8A=A0=E8=BD=BD=E9=98=B6=E6=AE=B5=E7=9A=84=E5=8F=96?= =?UTF-8?q?=E6=B6=88=E9=80=8F=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复 YAML 同步反序列化与构表阶段的取消处理,避免已取消会话被包装为配置加载失败 - 补充私有同步路径的回归测试,覆盖反序列化与构表阶段的 OperationCanceledException 透传语义 --- .../Config/YamlConfigLoaderTests.cs | 83 +++++++++++++++++++ GFramework.Game/Config/YamlConfigLoader.cs | 22 ++++- 2 files changed, 101 insertions(+), 4 deletions(-) diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs index 4d3a0baf..5cb22d08 100644 --- a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs +++ b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs @@ -4,6 +4,7 @@ using System.Threading; using GFramework.Core.Abstractions.Events; using GFramework.Game.Abstractions.Config; using GFramework.Game.Config; +using YamlDotNet.Serialization; namespace GFramework.Game.Tests.Config; @@ -2828,6 +2829,88 @@ public class YamlConfigLoaderTests Throws.InstanceOf()); } + /// + /// 验证同步反序列化阶段遇到已取消 token 时会直接透传 , + /// 避免把停止加载误报为 YAML 解析失败。 + /// + [Test] + public void DeserializeValue_Should_Preserve_OperationCanceledException_When_Cancellation_Is_Requested() + { + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", static config => config.Id); + var registration = GetSingleYamlTableRegistration(loader); + var deserializeValueMethod = registration.GetType() + .GetMethod("DeserializeValue", BindingFlags.Instance | BindingFlags.NonPublic); + + Assert.That(deserializeValueMethod, Is.Not.Null); + + using var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); + + var deserializer = new DeserializerBuilder().Build(); + var exception = Assert.Throws(() => + deserializeValueMethod!.Invoke( + registration, + new object?[] + { + deserializer, + Path.Combine(_rootPath, "monster"), + Path.Combine(_rootPath, "monster", "slime.yaml"), + null, + """ + id: 1 + name: Slime + hp: 10 + """, + cancellationTokenSource.Token + })); + + // 反射调用同步私有方法时会把原始异常包装为 TargetInvocationException。 + Assert.That(exception!.InnerException, Is.InstanceOf()); + } + + /// + /// 验证构建最终配置表阶段遇到已取消 token 时会继续透传 , + /// 避免热重载把提交前取消记录成构表失败。 + /// + [Test] + public void BuildLoadResult_Should_Preserve_OperationCanceledException_When_Cancellation_Is_Requested() + { + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", static config => config.Id); + var registration = GetSingleYamlTableRegistration(loader); + var buildLoadResultMethod = registration.GetType() + .GetMethod("BuildLoadResult", BindingFlags.Instance | BindingFlags.NonPublic); + + Assert.That(buildLoadResultMethod, Is.Not.Null); + + using var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); + + var exception = Assert.Throws(() => + buildLoadResultMethod!.Invoke( + registration, + new object?[] + { + Path.Combine(_rootPath, "monster"), + null, + new List + { + new() + { + Id = 1, + Name = "Slime", + Hp = 10 + } + }, + new List(), + cancellationTokenSource.Token + })); + + // 反射调用同步私有方法时会把原始异常包装为 TargetInvocationException。 + Assert.That(exception!.InnerException, Is.InstanceOf()); + } + /// /// 验证依赖关系仅来自 contains 子 schema 时,热重载仍会追踪该依赖并在目标表破坏引用后回滚。 /// diff --git a/GFramework.Game/Config/YamlConfigLoader.cs b/GFramework.Game/Config/YamlConfigLoader.cs index 33ff4931..d6faaaf0 100644 --- a/GFramework.Game/Config/YamlConfigLoader.cs +++ b/GFramework.Game/Config/YamlConfigLoader.cs @@ -484,7 +484,7 @@ public sealed class YamlConfigLoader : IConfigLoader referenceUsages, cancellationToken) .ConfigureAwait(false); - return BuildLoadResult(directoryPath, schema, values, referenceUsages); + return BuildLoadResult(directoryPath, schema, values, referenceUsages, cancellationToken); } private string GetValidatedDirectoryPath(string rootPath) @@ -526,7 +526,7 @@ public sealed class YamlConfigLoader : IConfigLoader cancellationToken.ThrowIfCancellationRequested(); var yaml = await ReadYamlAsync(directoryPath, file, schema, cancellationToken).ConfigureAwait(false); CollectReferenceUsages(referenceUsages, schema, file, yaml); - values.Add(DeserializeValue(deserializer, directoryPath, file, schema, yaml)); + values.Add(DeserializeValue(deserializer, directoryPath, file, schema, yaml, cancellationToken)); } return values; @@ -592,10 +592,12 @@ public sealed class YamlConfigLoader : IConfigLoader string directoryPath, string file, YamlConfigSchema? schema, - string yaml) + string yaml, + CancellationToken cancellationToken) { try { + cancellationToken.ThrowIfCancellationRequested(); var value = deserializer.Deserialize(yaml); if (value != null) { @@ -604,6 +606,11 @@ public sealed class YamlConfigLoader : IConfigLoader throw new InvalidOperationException("YAML content was deserialized to null."); } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // 同步反序列化阶段也要透传会话级取消,避免把停止加载误报为 YAML 解析失败。 + throw; + } catch (Exception exception) { throw ConfigLoadExceptionFactory.Create( @@ -622,10 +629,12 @@ public sealed class YamlConfigLoader : IConfigLoader string directoryPath, YamlConfigSchema? schema, List values, - List referenceUsages) + List referenceUsages, + CancellationToken cancellationToken) { try { + cancellationToken.ThrowIfCancellationRequested(); var table = new InMemoryConfigTable(values, _keySelector, _comparer); return new YamlTableLoadResult( Name, @@ -633,6 +642,11 @@ public sealed class YamlConfigLoader : IConfigLoader schema?.ReferencedTableNames ?? Array.Empty(), referenceUsages); } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // 构建最终配置表时继续保留原始取消语义,避免热重载把提交前取消记录成构表失败。 + throw; + } catch (Exception exception) { throw ConfigLoadExceptionFactory.Create(