diff --git a/GFramework.Core.Tests/Coroutine/CoroutineSchedulerAdvancedTests.cs b/GFramework.Core.Tests/Coroutine/CoroutineSchedulerAdvancedTests.cs index 0087b351..f262cd55 100644 --- a/GFramework.Core.Tests/Coroutine/CoroutineSchedulerAdvancedTests.cs +++ b/GFramework.Core.Tests/Coroutine/CoroutineSchedulerAdvancedTests.cs @@ -171,4 +171,63 @@ public sealed class CoroutineSchedulerAdvancedTests Assert.That(status, Is.EqualTo(CoroutineCompletionStatus.Faulted)); } + + /// + /// 验证完成状态缓存有固定上限,避免无限增长。 + /// + [Test] + public void CompletionStatusHistory_Should_Be_Bounded() + { + var timeSource = new FakeTimeSource(); + var scheduler = new CoroutineScheduler(timeSource); + var handles = new List(); + + IEnumerator ImmediateCoroutine() + { + yield break; + } + + for (var i = 0; i < 1100; i++) + { + handles.Add(scheduler.Run(ImmediateCoroutine())); + } + + Assert.That(scheduler.TryGetCompletionStatus(handles[0], out _), Is.False); + Assert.That(scheduler.TryGetCompletionStatus(handles[^1], out var latestStatus), Is.True); + Assert.That(latestStatus, Is.EqualTo(CoroutineCompletionStatus.Completed)); + } + + /// + /// 验证作为首个等待指令的 WaitForCoroutine 会立即启动子协程,并沿用父协程取消令牌。 + /// + [Test] + public async Task WaitForCoroutine_Should_Start_Child_During_Prewarm_And_Propagate_Cancellation() + { + var timeSource = new FakeTimeSource(); + var scheduler = new CoroutineScheduler(timeSource); + using var cancellationTokenSource = new CancellationTokenSource(); + + IEnumerator ChildCoroutine() + { + yield return new Delay(10); + } + + IEnumerator ParentCoroutine() + { + yield return new WaitForCoroutine(ChildCoroutine()); + } + + var handle = scheduler.Run(ParentCoroutine(), cancellationToken: cancellationTokenSource.Token); + + Assert.That(scheduler.ActiveCoroutineCount, Is.EqualTo(2)); + + cancellationTokenSource.Cancel(); + timeSource.Advance(0.1); + scheduler.Update(); + + var status = await scheduler.WaitForCompletionAsync(handle); + + Assert.That(status, Is.EqualTo(CoroutineCompletionStatus.Cancelled)); + Assert.That(scheduler.ActiveCoroutineCount, Is.EqualTo(0)); + } } \ No newline at end of file diff --git a/GFramework.Core/Coroutine/CoroutineScheduler.cs b/GFramework.Core/Coroutine/CoroutineScheduler.cs index b11a5e57..0a24ce6d 100644 --- a/GFramework.Core/Coroutine/CoroutineScheduler.cs +++ b/GFramework.Core/Coroutine/CoroutineScheduler.cs @@ -34,10 +34,13 @@ public sealed class CoroutineScheduler( ITimeSource? realtimeTimeSource = null, CoroutineExecutionStage executionStage = CoroutineExecutionStage.Update) { + private const int CompletionStatusHistoryLimit = 1024; + private readonly Dictionary> _completionSources = new(); private readonly Dictionary _completionStatuses = new(); + private readonly Queue _completionStatusOrder = new(); private readonly Dictionary> _grouped = new(); private readonly ILogger _logger = LoggerFactoryResolver.Provider.CreateLogger(nameof(CoroutineScheduler)); private readonly Dictionary _metadata = new(); @@ -218,6 +221,7 @@ public sealed class CoroutineScheduler( var slot = new CoroutineSlot { + CancellationToken = cancellationToken, Enumerator = coroutine, State = CoroutineState.Running, Handle = handle, @@ -386,7 +390,14 @@ public sealed class CoroutineScheduler( { case WaitForCoroutine waitForCoroutine: { - var targetHandle = Run(waitForCoroutine.Coroutine); + var targetHandle = Run(waitForCoroutine.Coroutine, cancellationToken: slot.CancellationToken); + if (!targetHandle.IsValid) + { + waitForCoroutine.Complete(); + slot.Waiting = null; + break; + } + slot.Waiting = waitForCoroutine; WaitForCoroutine(slot.Handle, targetHandle); break; @@ -596,6 +607,8 @@ public sealed class CoroutineScheduler( _tagged.Clear(); _grouped.Clear(); _waiting.Clear(); + _completionStatuses.Clear(); + _completionStatusOrder.Clear(); _nextSlot = 0; ActiveCoroutineCount = 0; @@ -629,7 +642,7 @@ public sealed class CoroutineScheduler( } else { - slot.Waiting = slot.Enumerator.Current; + HandleYieldInstruction(slot, slot.Enumerator.Current); } } catch (Exception ex) @@ -712,7 +725,7 @@ public sealed class CoroutineScheduler( source.TrySetResult(completionStatus); } - _completionStatuses[handle] = completionStatus; + RecordCompletionStatus(handle, completionStatus); OnCoroutineFinished?.Invoke(handle, completionStatus, exception); } @@ -892,6 +905,23 @@ public sealed class CoroutineScheduler( _statistics.PausedCount = _pausedCount; } + /// + /// 记录协程最终状态,并对历史缓存施加固定上限,避免完成状态字典无限增长。 + /// + /// 已结束的协程句柄。 + /// 协程最终状态。 + private void RecordCompletionStatus(CoroutineHandle handle, CoroutineCompletionStatus completionStatus) + { + _completionStatuses[handle] = completionStatus; + _completionStatusOrder.Enqueue(handle); + + while (_completionStatusOrder.Count > CompletionStatusHistoryLimit) + { + var expiredHandle = _completionStatusOrder.Dequeue(); + _completionStatuses.Remove(expiredHandle); + } + } + /// /// 为协程添加标签。 /// diff --git a/GFramework.Core/Coroutine/CoroutineSlot.cs b/GFramework.Core/Coroutine/CoroutineSlot.cs index e4478037..1fb11994 100644 --- a/GFramework.Core/Coroutine/CoroutineSlot.cs +++ b/GFramework.Core/Coroutine/CoroutineSlot.cs @@ -13,6 +13,12 @@ internal sealed class CoroutineSlot /// public CancellationTokenRegistration CancellationRegistration; + /// + /// 创建该协程时传入的取消令牌。 + /// 当协程启动子协程时,会把同一个取消令牌继续传递下去,以保持父子协程的取消语义一致。 + /// + public CancellationToken CancellationToken; + /// /// 协程枚举器,包含协程的执行逻辑 /// diff --git a/GFramework.Godot.Tests/Coroutine/GodotTimeSourceTests.cs b/GFramework.Godot.Tests/Coroutine/GodotTimeSourceTests.cs index f1527dac..3f46fb1b 100644 --- a/GFramework.Godot.Tests/Coroutine/GodotTimeSourceTests.cs +++ b/GFramework.Godot.Tests/Coroutine/GodotTimeSourceTests.cs @@ -1,6 +1,5 @@ using System.Collections.Generic; using GFramework.Godot.Coroutine; -using NUnit.Framework; namespace GFramework.Godot.Tests.Coroutine; @@ -49,4 +48,25 @@ public sealed class GodotTimeSourceTests Assert.That(timeSource.DeltaTime, Is.EqualTo(0.75).Within(0.0001)); Assert.That(timeSource.CurrentTime, Is.EqualTo(2.0).Within(0.0001)); } + + /// + /// 验证绝对时间源在回拨时仍保持单调,不会把 CurrentTime 拉回去。 + /// + [Test] + public void Update_Should_Keep_Absolute_Time_Monotonic_When_Provider_Goes_Backwards() + { + var values = new Queue([5.0, 4.0, 6.5]); + var timeSource = new GodotTimeSource(() => values.Dequeue(), useAbsoluteTime: true); + + timeSource.Update(); + timeSource.Update(); + + Assert.That(timeSource.DeltaTime, Is.EqualTo(0).Within(0.0001)); + Assert.That(timeSource.CurrentTime, Is.EqualTo(5.0).Within(0.0001)); + + timeSource.Update(); + + Assert.That(timeSource.DeltaTime, Is.EqualTo(1.5).Within(0.0001)); + Assert.That(timeSource.CurrentTime, Is.EqualTo(6.5).Within(0.0001)); + } } \ No newline at end of file diff --git a/GFramework.Godot/Coroutine/GodotTimeSource.cs b/GFramework.Godot/Coroutine/GodotTimeSource.cs index ffdd3ae4..06cd5cbd 100644 --- a/GFramework.Godot/Coroutine/GodotTimeSource.cs +++ b/GFramework.Godot/Coroutine/GodotTimeSource.cs @@ -1,5 +1,4 @@ using GFramework.Core.Abstractions.Coroutine; -using Godot; namespace GFramework.Godot.Coroutine; @@ -47,9 +46,11 @@ public sealed class GodotTimeSource(Func timeProvider, bool useAbsoluteT return; } - DeltaTime = Math.Max(0, value - _lastAbsoluteTime); - _lastAbsoluteTime = value; - CurrentTime = value; + // 对绝对时间源做单调钳制,避免 provider 回拨后把 CurrentTime 也拉回去。 + var nextTime = Math.Max(value, _lastAbsoluteTime); + DeltaTime = nextTime - _lastAbsoluteTime; + _lastAbsoluteTime = nextTime; + CurrentTime = nextTime; return; } diff --git a/GFramework.Godot/Coroutine/Timing.cs b/GFramework.Godot/Coroutine/Timing.cs index 983fdc6f..774bfcc1 100644 --- a/GFramework.Godot/Coroutine/Timing.cs +++ b/GFramework.Godot/Coroutine/Timing.cs @@ -1,9 +1,5 @@ using System.Reflection; using GFramework.Core.Abstractions.Coroutine; -using GFramework.Core.Coroutine; -using GFramework.Core.Coroutine.Instructions; -using GFramework.Godot.Extensions; -using Godot; namespace GFramework.Godot.Coroutine; @@ -470,6 +466,11 @@ public partial class Timing : Node return handle; } + if (!GetScheduler(segment).IsCoroutineAlive(handle)) + { + return handle; + } + RegisterOwnedCoroutine(owner, handle); return handle; } @@ -515,7 +516,13 @@ public partial class Timing : Node /// 被终止的协程数量。 public static int KillCoroutines(Node owner) { - return Instance.KillOwnedCoroutinesOnInstance(owner); + var count = 0; + foreach (var timing in EnumerateActiveInstances()) + { + count += timing.KillOwnedCoroutinesOnInstance(owner); + } + + return count; } /// @@ -562,7 +569,13 @@ public partial class Timing : Node /// 该节点当前归属的活跃协程数量。 public static int GetOwnedCoroutineCount(Node owner) { - return Instance.GetOwnedCoroutineCountOnInstance(owner); + var count = 0; + foreach (var timing in EnumerateActiveInstances()) + { + count += timing.GetOwnedCoroutineCountOnInstance(owner); + } + + return count; } /// @@ -683,6 +696,15 @@ public partial class Timing : Node return id < ActiveInstances.Length ? ActiveInstances[id] : null; } + /// + /// 枚举所有当前已注册的 Timing 实例。 + /// + /// 活跃 Timing 实例序列。 + private static IEnumerable EnumerateActiveInstances() + { + return ActiveInstances.Where(static timing => timing is not null).Select(static timing => timing!); + } + /// /// 检查节点是否处于有效状态。 ///