fix(game): 修复同步加载阶段的取消透传

- 修复 YAML 同步反序列化与构表阶段的取消处理,避免已取消会话被包装为配置加载失败
- 补充私有同步路径的回归测试,覆盖反序列化与构表阶段的 OperationCanceledException 透传语义
This commit is contained in:
gewuyou 2026-04-27 16:50:44 +08:00
parent 953a03b937
commit 1753778cae
2 changed files with 101 additions and 4 deletions

View File

@ -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<OperationCanceledException>());
}
/// <summary>
/// 验证同步反序列化阶段遇到已取消 token 时会直接透传 <see cref="OperationCanceledException" />
/// 避免把停止加载误报为 YAML 解析失败。
/// </summary>
[Test]
public void DeserializeValue_Should_Preserve_OperationCanceledException_When_Cancellation_Is_Requested()
{
var loader = new YamlConfigLoader(_rootPath)
.RegisterTable<int, MonsterConfigStub>("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<TargetInvocationException>(() =>
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<OperationCanceledException>());
}
/// <summary>
/// 验证构建最终配置表阶段遇到已取消 token 时会继续透传 <see cref="OperationCanceledException" />
/// 避免热重载把提交前取消记录成构表失败。
/// </summary>
[Test]
public void BuildLoadResult_Should_Preserve_OperationCanceledException_When_Cancellation_Is_Requested()
{
var loader = new YamlConfigLoader(_rootPath)
.RegisterTable<int, MonsterConfigStub>("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<TargetInvocationException>(() =>
buildLoadResultMethod!.Invoke(
registration,
new object?[]
{
Path.Combine(_rootPath, "monster"),
null,
new List<MonsterConfigStub>
{
new()
{
Id = 1,
Name = "Slime",
Hp = 10
}
},
new List<YamlConfigReferenceUsage>(),
cancellationTokenSource.Token
}));
// 反射调用同步私有方法时会把原始异常包装为 TargetInvocationException。
Assert.That(exception!.InnerException, Is.InstanceOf<OperationCanceledException>());
}
/// <summary>
/// 验证依赖关系仅来自 <c>contains</c> 子 schema 时,热重载仍会追踪该依赖并在目标表破坏引用后回滚。
/// </summary>

View File

@ -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<TValue>(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<TValue> values,
List<YamlConfigReferenceUsage> referenceUsages)
List<YamlConfigReferenceUsage> referenceUsages,
CancellationToken cancellationToken)
{
try
{
cancellationToken.ThrowIfCancellationRequested();
var table = new InMemoryConfigTable<TKey, TValue>(values, _keySelector, _comparer);
return new YamlTableLoadResult(
Name,
@ -633,6 +642,11 @@ public sealed class YamlConfigLoader : IConfigLoader
schema?.ReferencedTableNames ?? Array.Empty<string>(),
referenceUsages);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// 构建最终配置表时继续保留原始取消语义,避免热重载把提交前取消记录成构表失败。
throw;
}
catch (Exception exception)
{
throw ConfigLoadExceptionFactory.Create(