Merge pull request #259 from GeWuYou/feat/coroutine-optimization

test(coroutine): 补齐 Godot 协程宿主回归测试
This commit is contained in:
gewuyou 2026-04-20 11:49:57 +08:00 committed by GitHub
commit 702dec6ed1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 543 additions and 23 deletions

View File

@ -1,6 +1,6 @@
--- ---
name: gframework-pr-review name: gframework-pr-review
description: Repository-specific GitHub PR review workflow for the GFramework repo. Use when Codex needs to inspect the GitHub pull request for the current branch, extract CodeRabbit summary/comments, read failed checks or failed test signals from the PR page, and then verify which findings should be fixed in the local codebase. Trigger explicitly with $gframework-pr-review or with prompts such as "look at the current PR", "extract CodeRabbit comments", or "check Failed Tests on the PR". description: Repository-specific GitHub PR review workflow for the GFramework repo. Use when Codex needs to inspect the GitHub pull request for the current branch, extract CodeRabbit summary/comments, read failed checks, MegaLinter warnings, or failed test signals from the PR page, and then verify which findings should be fixed in the local codebase. Trigger explicitly with $gframework-pr-review or with prompts such as "look at the current PR", "extract CodeRabbit comments", or "check Failed Tests on the PR".
--- ---
# GFramework PR Review # GFramework PR Review
@ -16,12 +16,12 @@ Shortcut: `$gframework-pr-review`
3. Run `scripts/fetch_current_pr_review.py` to: 3. Run `scripts/fetch_current_pr_review.py` to:
- locate the PR for the current branch through the GitHub PR API - locate the PR for the current branch through the GitHub PR API
- fetch PR metadata, issue comments, reviews, and review comments through the GitHub API - fetch PR metadata, issue comments, reviews, and review comments through the GitHub API
- extract `Summary by CodeRabbit` and CTRF test reports from issue comments - extract `Summary by CodeRabbit`、GitHub Actions bot comments such as `MegaLinter analysis: Success with warnings`and CTRF test reports from issue comments
- fetch the latest head commit review threads from the GitHub PR API - fetch the latest head commit review threads from the GitHub PR API
- prefer unresolved review threads on the latest head commit over older summary-only signals - prefer unresolved review threads on the latest head commit over older summary-only signals
- extract failed checks and test-report signals such as `Failed Tests` or `No failed tests in this run` - extract failed checks, MegaLinter detailed issues, and test-report signals such as `Failed Tests` or `No failed tests in this run`
4. Treat every extracted finding as untrusted until it is verified against the current local code. 4. Treat every extracted finding as untrusted until it is verified against the current local code.
5. Only fix comments that still apply to the checked-out branch. Ignore stale or already-resolved findings. 5. Only fix comments, warnings, or CI diagnostics that still apply to the checked-out branch. Ignore stale or already-resolved findings.
6. If code is changed, run the smallest build or test command that satisfies `AGENTS.md`. 6. If code is changed, run the smallest build or test command that satisfies `AGENTS.md`.
## Commands ## Commands
@ -43,6 +43,7 @@ The script should produce:
- Latest head commit review metadata and review threads - Latest head commit review metadata and review threads
- Unresolved latest-commit review threads after reply-thread folding - Unresolved latest-commit review threads after reply-thread folding
- Pre-merge failed checks, if present - Pre-merge failed checks, if present
- Latest MegaLinter status and any detailed issues posted by `github-actions[bot]`
- Test summary, including failed-test signals when present - Test summary, including failed-test signals when present
- Parse warnings only when both the primary API source and the intended fallback signal are unavailable - Parse warnings only when both the primary API source and the intended fallback signal are unavailable
@ -52,6 +53,7 @@ The script should produce:
- If GitHub access fails because of proxy configuration, rerun the fetch with proxy variables removed. - If GitHub access fails because of proxy configuration, rerun the fetch with proxy variables removed.
- Prefer GitHub API results over PR HTML. The PR HTML page is now a fallback/debugging source, not the primary source of truth. - Prefer GitHub API results over PR HTML. The PR HTML page is now a fallback/debugging source, not the primary source of truth.
- If the summary block and the latest head review threads disagree, trust the latest unresolved head-review threads and treat older summary findings as stale until re-verified locally. - If the summary block and the latest head review threads disagree, trust the latest unresolved head-review threads and treat older summary findings as stale until re-verified locally.
- Treat GitHub Actions comments with `Success with warnings` as actionable review input when they include concrete linter diagnostics such as `MegaLinter` detailed issues; do not skip them just because the parent check is green.
## Example Triggers ## Example Triggers

View File

@ -24,6 +24,7 @@ DEFAULT_WINDOWS_GIT = "/mnt/d/Tool/Development Tools/Git/cmd/git.exe"
GIT_ENVIRONMENT_KEY = "GFRAMEWORK_WINDOWS_GIT" GIT_ENVIRONMENT_KEY = "GFRAMEWORK_WINDOWS_GIT"
USER_AGENT = "codex-gframework-pr-review" USER_AGENT = "codex-gframework-pr-review"
CODERABBIT_LOGIN = "coderabbitai[bot]" CODERABBIT_LOGIN = "coderabbitai[bot]"
GITHUB_ACTIONS_LOGIN = "github-actions[bot]"
REVIEW_COMMENT_ADDRESSED_MARKER = "<!-- <review_comment_addressed> -->" REVIEW_COMMENT_ADDRESSED_MARKER = "<!-- <review_comment_addressed> -->"
VISIBLE_ADDRESSED_IN_COMMIT_PATTERN = re.compile(r"\s*Addressed in commit\s+[0-9a-f]{7,40}", re.I) VISIBLE_ADDRESSED_IN_COMMIT_PATTERN = re.compile(r"\s*Addressed in commit\s+[0-9a-f]{7,40}", re.I)
DEFAULT_REQUEST_TIMEOUT_SECONDS = 60 DEFAULT_REQUEST_TIMEOUT_SECONDS = 60
@ -156,6 +157,10 @@ def strip_tags(text: str) -> str:
return collapse_whitespace(re.sub(r"<[^>]+>", " ", text)) return collapse_whitespace(re.sub(r"<[^>]+>", " ", text))
def strip_markdown_links(text: str) -> str:
return re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text)
def extract_section(text: str, start_marker: str, end_markers: list[str]) -> str | None: def extract_section(text: str, start_marker: str, end_markers: list[str]) -> str | None:
start = text.find(start_marker) start = text.find(start_marker)
if start < 0: if start < 0:
@ -252,6 +257,60 @@ def parse_actionable_comments(actionable_block: str) -> dict[str, Any]:
} }
def parse_megalinter_comment(comment_body: str) -> dict[str, Any]:
normalized_body = html.unescape(comment_body).strip()
summary_match = re.search(
r"##\s*(?P<badges>.*?)\[MegaLinter\]\([^)]+\)\s+analysis:\s+\[(?P<status>[^\]]+)\]\((?P<run_url>[^)]+)\)",
normalized_body,
)
report: dict[str, Any] = {
"status": summary_match.group("status").strip() if summary_match else "",
"run_url": summary_match.group("run_url").strip() if summary_match else "",
"badges": collapse_whitespace(summary_match.group("badges")) if summary_match else "",
"descriptor_rows": [],
"detailed_issues": [],
"raw": normalized_body,
}
table_match = re.search(
r"\| Descriptor .*?\|\n\|[-| :]+\|\n(?P<rows>(?:\|.*\|\n?)+)",
normalized_body,
re.S,
)
if table_match is not None:
for raw_line in table_match.group("rows").splitlines():
line = raw_line.strip()
if not line.startswith("|"):
continue
parts = [collapse_whitespace(strip_markdown_links(part)) for part in line.strip("|").split("|")]
if len(parts) != 7:
continue
report["descriptor_rows"].append(
{
"descriptor": parts[0],
"linter": parts[1],
"files": parts[2],
"fixed": parts[3],
"errors": parts[4],
"warnings": parts[5],
"elapsed_time": parts[6],
}
)
for summary, details in re.findall(r"<summary>(.*?)</summary>\s*```(.*?)```", normalized_body, re.S):
report["detailed_issues"].append(
{
"summary": collapse_whitespace(strip_tags(summary)),
"details": details.strip(),
}
)
return report
def parse_test_report(block: str) -> dict[str, Any]: def parse_test_report(block: str) -> dict[str, Any]:
report: dict[str, Any] = { report: dict[str, Any] = {
"raw": block.strip(), "raw": block.strip(),
@ -475,11 +534,18 @@ def build_result(pr_number: int, branch: str) -> dict[str, Any]:
issue_comments, issue_comments,
lambda body: "CTRF PR COMMENT TAG:" in body or "### Test Results" in body, lambda body: "CTRF PR COMMENT TAG:" in body or "### Test Results" in body,
) )
megalinter_block = select_latest_comment_body(
issue_comments,
lambda body: "MegaLinter" in body and "Detailed Issues" in body,
required_user=GITHUB_ACTIONS_LOGIN,
)
if not summary_block: if not summary_block:
warnings.append("CodeRabbit summary block was not found in issue comments.") warnings.append("CodeRabbit summary block was not found in issue comments.")
if not test_blocks: if not test_blocks:
warnings.append("PR test-report block was not found in issue comments.") warnings.append("PR test-report block was not found in issue comments.")
if not megalinter_block:
warnings.append("MegaLinter report block was not found in issue comments.")
latest_commit_review: dict[str, Any] = {} latest_commit_review: dict[str, Any] = {}
try: try:
@ -506,6 +572,7 @@ def build_result(pr_number: int, branch: str) -> dict[str, Any]:
}, },
"coderabbit_comments": parse_actionable_comments(actionable_block) if actionable_block else {}, "coderabbit_comments": parse_actionable_comments(actionable_block) if actionable_block else {},
"latest_commit_review": latest_commit_review, "latest_commit_review": latest_commit_review,
"megalinter_report": parse_megalinter_comment(megalinter_block) if megalinter_block else {},
"test_reports": [parse_test_report(block) for block in test_blocks], "test_reports": [parse_test_report(block) for block in test_blocks],
"parse_warnings": warnings, "parse_warnings": warnings,
} }
@ -569,6 +636,31 @@ def format_text(result: dict[str, Any]) -> str:
" Note: thread is still open; treat the visible 'Addressed in commit ...' text as unverified until local code matches." " Note: thread is still open; treat the visible 'Addressed in commit ...' text as unverified until local code matches."
) )
megalinter_report = result.get("megalinter_report", {})
if megalinter_report:
lines.append("")
lines.append(
"MegaLinter: "
f"{megalinter_report.get('status', 'unknown')}"
+ (
f" ({megalinter_report.get('run_url', '')})"
if megalinter_report.get("run_url")
else ""
)
)
descriptor_rows = megalinter_report.get("descriptor_rows", [])
for descriptor_row in descriptor_rows:
lines.append(
"- "
f"{descriptor_row['descriptor']} / {descriptor_row['linter']}: "
f"errors={descriptor_row['errors']} warnings={descriptor_row['warnings']} files={descriptor_row['files']}"
)
for issue in megalinter_report.get("detailed_issues", []):
lines.append(f"- Detailed issue: {issue['summary']}")
lines.append(f" {collapse_whitespace(issue['details'])}")
lines.append("") lines.append("")
lines.append(f"Test reports: {len(result['test_reports'])}") lines.append(f"Test reports: {len(result['test_reports'])}")
for index, report in enumerate(result["test_reports"], start=1): for index, report in enumerate(result["test_reports"], start=1):

View File

@ -45,6 +45,9 @@ ENABLE_LINTERS:
# 设置 C# 代码风格检查的参数和验证级别 # 设置 C# 代码风格检查的参数和验证级别
# ======================== # ========================
CSHARP_DOTNET_FORMAT_ARGUMENTS: CSHARP_DOTNET_FORMAT_ARGUMENTS:
# 仓库根目录同时存在 GFramework.sln 与 GFramework.csproj
# 显式指定 workspace避免 dotnet format 在 CI 中因自动探测歧义直接异常退出。
- "GFramework.sln"
- "--severity" - "--severity"
- "info" - "info"
- "--verify-no-changes" - "--verify-no-changes"
@ -83,4 +86,3 @@ GITHUB_COMMENT_REPORTER: true
PARALLEL: true PARALLEL: true
SHOW_ELAPSED_TIME: true SHOW_ELAPSED_TIME: true
VALIDATE_ALL_CODEBASE: false VALIDATE_ALL_CODEBASE: false

View File

@ -0,0 +1,197 @@
using GFramework.Core.Abstractions.Coroutine;
using GFramework.Core.Coroutine;
using GFramework.Core.Coroutine.Instructions;
using GFramework.Godot.Coroutine;
using System.Runtime.CompilerServices;
namespace GFramework.Godot.Tests.Coroutine;
/// <summary>
/// 验证 <see cref="Timing" /> 在纯托管测试宿主下仍保持与真实 Godot 生命周期一致的阶段语义。
/// </summary>
[TestFixture]
[NonParallelizable]
public sealed class TimingTests
{
private Timing _timing = null!;
/// <summary>
/// 为每个测试准备独立的 Timing 宿主,避免静态实例槽位相互污染。
/// </summary>
[SetUp]
public void SetUp()
{
// Timing 继承自 Godot.Node在纯 dotnet test 宿主中直接运行原生构造函数会触发测试进程崩溃。
// 这里仅为调度语义测试创建未初始化对象,再由 InitializeForTests 补齐纯托管字段与调度器状态。
_timing = (Timing)RuntimeHelpers.GetUninitializedObject(typeof(Timing));
_timing.InitializeForTests();
}
/// <summary>
/// 清理测试宿主注册的调度器与实例槽位。
/// </summary>
[TearDown]
public void TearDown()
{
_timing.DisposeForTests();
}
/// <summary>
/// 验证暂停场景时只会冻结普通 Process 协程,忽略暂停段仍会继续推进。
/// </summary>
[Test]
public void AdvanceProcessFrameForTests_Should_Freeze_Process_Segment_But_Keep_IgnorePause_Segment_Running()
{
var executedSegments = new List<string>();
var processHandle = _timing.RunCoroutineOnInstance(
CompleteAfterOneFrame(() => executedSegments.Add("process")),
Segment.Process);
var ignorePauseHandle = _timing.RunCoroutineOnInstance(
CompleteAfterOneFrame(() => executedSegments.Add("ignore-pause")),
Segment.ProcessIgnorePause);
_timing.AdvanceProcessFrameForTests(paused: true);
Assert.Multiple(() =>
{
Assert.That(executedSegments, Is.EqualTo(new[] { "ignore-pause" }));
Assert.That(_timing.ProcessCoroutines, Is.EqualTo(1));
Assert.That(_timing.ProcessIgnorePauseCoroutines, Is.EqualTo(0));
Assert.That(_timing.GetSchedulerForTests(Segment.Process).IsCoroutineAlive(processHandle), Is.True);
Assert.That(
_timing.GetSchedulerForTests(Segment.ProcessIgnorePause)
.TryGetCompletionStatus(ignorePauseHandle, out var status),
Is.True);
Assert.That(status, Is.EqualTo(CoroutineCompletionStatus.Completed));
});
}
/// <summary>
/// 验证 Physics 帧只会推进 Physics 段,不会提前消费普通 Process 段的等待。
/// </summary>
[Test]
public void AdvancePhysicsFrameForTests_Should_Only_Advance_Physics_Segment()
{
var executedSegments = new List<string>();
var processHandle = _timing.RunCoroutineOnInstance(
CompleteAfterOneFrame(() => executedSegments.Add("process")),
Segment.Process);
var physicsHandle = _timing.RunCoroutineOnInstance(
CompleteAfterOneFrame(() => executedSegments.Add("physics")),
Segment.PhysicsProcess);
_timing.AdvancePhysicsFrameForTests();
Assert.Multiple(() =>
{
Assert.That(executedSegments, Is.EqualTo(new[] { "physics" }));
Assert.That(_timing.GetSchedulerForTests(Segment.Process).IsCoroutineAlive(processHandle), Is.True);
Assert.That(_timing.GetSchedulerForTests(Segment.PhysicsProcess).IsCoroutineAlive(physicsHandle), Is.False);
});
}
/// <summary>
/// 验证帧尾段会在 Process 段之后执行,保持与生产宿主 `_Process -> CallDeferred` 的顺序一致。
/// </summary>
[Test]
public void AdvanceProcessFrameForTests_Should_Run_Deferred_Segment_After_Process_Segment()
{
var executionOrder = new List<string>();
_timing.RunCoroutineOnInstance(
CompleteAfterOneFrame(() => executionOrder.Add("process")),
Segment.Process);
_timing.RunCoroutineOnInstance(
CompleteAfterOneFrame(() => executionOrder.Add("deferred")),
Segment.DeferredProcess);
_timing.AdvanceProcessFrameForTests(paused: false);
Assert.That(executionOrder, Is.EqualTo(new[] { "process", "deferred" }));
}
/// <summary>
/// 验证 <see cref="WaitForFixedUpdate" /> 只会在 Physics 段完成,避免阶段型等待被错误地提前消费。
/// </summary>
[Test]
public void WaitForFixedUpdate_Should_Only_Complete_On_Physics_Segment()
{
var processCompletions = 0;
var physicsCompletions = 0;
var processHandle = _timing.RunCoroutineOnInstance(
CompleteAfterInstruction(new WaitForFixedUpdate(), () => processCompletions++),
Segment.Process);
var physicsHandle = _timing.RunCoroutineOnInstance(
CompleteAfterInstruction(new WaitForFixedUpdate(), () => physicsCompletions++),
Segment.PhysicsProcess);
_timing.AdvanceProcessFrameForTests(paused: false);
_timing.AdvancePhysicsFrameForTests();
Assert.Multiple(() =>
{
Assert.That(processCompletions, Is.EqualTo(0));
Assert.That(physicsCompletions, Is.EqualTo(1));
Assert.That(_timing.GetSchedulerForTests(Segment.Process).IsCoroutineAlive(processHandle), Is.True);
Assert.That(_timing.GetSchedulerForTests(Segment.PhysicsProcess).IsCoroutineAlive(physicsHandle), Is.False);
});
}
/// <summary>
/// 验证 <see cref="WaitForEndOfFrame" /> 只会在 Deferred 段完成,避免提前穿透到普通 Process 段。
/// </summary>
[Test]
public void WaitForEndOfFrame_Should_Only_Complete_On_Deferred_Segment()
{
var processCompletions = 0;
var deferredCompletions = 0;
var processHandle = _timing.RunCoroutineOnInstance(
CompleteAfterInstruction(new WaitForEndOfFrame(), () => processCompletions++),
Segment.Process);
var deferredHandle = _timing.RunCoroutineOnInstance(
CompleteAfterInstruction(new WaitForEndOfFrame(), () => deferredCompletions++),
Segment.DeferredProcess);
_timing.AdvanceProcessFrameForTests(paused: false);
Assert.Multiple(() =>
{
Assert.That(processCompletions, Is.EqualTo(0));
Assert.That(deferredCompletions, Is.EqualTo(1));
Assert.That(_timing.GetSchedulerForTests(Segment.Process).IsCoroutineAlive(processHandle), Is.True);
Assert.That(_timing.GetSchedulerForTests(Segment.DeferredProcess).IsCoroutineAlive(deferredHandle), Is.False);
});
}
/// <summary>
/// 构造一个在单帧等待后执行回调的测试协程。
/// </summary>
/// <param name="onCompleted">等待完成后执行的回调。</param>
/// <returns>供 Timing 运行的协程枚举器。</returns>
private static IEnumerator<IYieldInstruction> CompleteAfterOneFrame(Action onCompleted)
{
ArgumentNullException.ThrowIfNull(onCompleted);
yield return new WaitOneFrame();
onCompleted();
}
/// <summary>
/// 构造一个在指定等待指令完成后执行回调的测试协程。
/// </summary>
/// <param name="instruction">要验证的等待指令。</param>
/// <param name="onCompleted">等待完成后执行的回调。</param>
/// <returns>供 Timing 运行的协程枚举器。</returns>
private static IEnumerator<IYieldInstruction> CompleteAfterInstruction(
IYieldInstruction instruction,
Action onCompleted)
{
ArgumentNullException.ThrowIfNull(instruction);
ArgumentNullException.ThrowIfNull(onCompleted);
yield return instruction;
onCompleted();
}
}

View File

@ -0,0 +1,160 @@
using System;
using System.Collections.Generic;
using GFramework.Core.Abstractions.Coroutine;
using GFramework.Core.Coroutine;
namespace GFramework.Godot.Coroutine;
public partial class Timing
{
/// <summary>
/// 使用可控时间源初始化当前 <see cref="Timing" /> 实例,供纯托管测试验证宿主阶段语义。
/// </summary>
/// <param name="processDeltaProvider">`Process` 段的增量提供器。</param>
/// <param name="physicsDeltaProvider">`PhysicsProcess` 段的增量提供器。</param>
/// <param name="deferredDeltaProvider">`DeferredProcess` 段的增量提供器。</param>
/// <remarks>
/// 该入口只用于测试宿主驱动顺序,不会挂接真实场景树,也不会暴露给运行时调用方。
/// 由于协程句柄包含实例槽位前缀,这里仍会注册实例槽位,便于沿用生产代码的查询与控制路径。
/// </remarks>
internal void InitializeForTests(
Func<double>? processDeltaProvider = null,
Func<double>? physicsDeltaProvider = null,
Func<double>? deferredDeltaProvider = null)
{
_instanceId = 1;
_ownedCoroutineRegistrations ??= new Dictionary<CoroutineHandle, OwnedCoroutineRegistration>();
_ownedCoroutinesByNode ??= new Dictionary<ulong, HashSet<CoroutineHandle>>();
RegisterInstance();
_processTimeSource = new GodotTimeSource(processDeltaProvider ?? DefaultDeltaProvider);
_processRealtimeTimeSource = new GodotTimeSource(processDeltaProvider ?? DefaultDeltaProvider);
_processIgnorePauseTimeSource = new GodotTimeSource(processDeltaProvider ?? DefaultDeltaProvider);
_processIgnorePauseRealtimeTimeSource = new GodotTimeSource(processDeltaProvider ?? DefaultDeltaProvider);
_physicsTimeSource = new GodotTimeSource(physicsDeltaProvider ?? DefaultDeltaProvider);
_physicsRealtimeTimeSource = new GodotTimeSource(physicsDeltaProvider ?? DefaultDeltaProvider);
_deferredTimeSource = new GodotTimeSource(deferredDeltaProvider ?? processDeltaProvider ?? DefaultDeltaProvider);
_deferredRealtimeTimeSource =
new GodotTimeSource(deferredDeltaProvider ?? processDeltaProvider ?? DefaultDeltaProvider);
_processScheduler = new CoroutineScheduler(
_processTimeSource,
_instanceId,
256,
false,
_processRealtimeTimeSource,
CoroutineExecutionStage.Update);
_processIgnorePauseScheduler = new CoroutineScheduler(
_processIgnorePauseTimeSource,
_instanceId,
256,
false,
_processIgnorePauseRealtimeTimeSource,
CoroutineExecutionStage.Update);
_physicsScheduler = new CoroutineScheduler(
_physicsTimeSource,
_instanceId,
128,
false,
_physicsRealtimeTimeSource,
CoroutineExecutionStage.FixedUpdate);
_deferredScheduler = new CoroutineScheduler(
_deferredTimeSource,
_instanceId,
64,
false,
_deferredRealtimeTimeSource,
CoroutineExecutionStage.EndOfFrame);
AttachSchedulerLifecycleHandlers(ProcessScheduler);
AttachSchedulerLifecycleHandlers(ProcessIgnorePauseScheduler);
AttachSchedulerLifecycleHandlers(PhysicsScheduler);
AttachSchedulerLifecycleHandlers(DeferredScheduler);
}
/// <summary>
/// 以测试宿主的方式推进一次 Process 帧。
/// </summary>
/// <param name="paused">
/// 指示当前帧是否视为场景暂停。
/// 暂停时仅推进 `ProcessIgnorePause` 段,并跳过 `DeferredProcess`,以匹配生产宿主逻辑。
/// </param>
internal void AdvanceProcessFrameForTests(bool paused)
{
if (!paused)
{
_processScheduler?.Update();
}
_processIgnorePauseScheduler?.Update();
_frameCounter++;
if (!paused)
{
_deferredScheduler?.Update();
}
}
/// <summary>
/// 以测试宿主的方式推进一次 Physics 帧。
/// </summary>
internal void AdvancePhysicsFrameForTests()
{
_physicsScheduler?.Update();
}
/// <summary>
/// 获取指定分段对应的调度器,供测试读取完成状态与快照。
/// </summary>
/// <param name="segment">目标分段。</param>
/// <returns>对应分段的调度器实例。</returns>
internal CoroutineScheduler GetSchedulerForTests(Segment segment)
{
return GetScheduler(segment);
}
/// <summary>
/// 清理测试初始化留下的实例槽位与调度器状态,避免跨测试污染静态单例表。
/// 仅当当前测试宿主仍持有共享单例引用时才会清理 `_instance`,以免误伤同进程内的其他宿主。
/// </summary>
internal void DisposeForTests()
{
DetachAllOwnedRegistrations();
ClearOnInstance();
if (_instanceId < ActiveInstances.Length)
{
ActiveInstances[_instanceId] = null;
}
CleanupInstanceIfNecessary(this);
_processScheduler = null;
_processIgnorePauseScheduler = null;
_physicsScheduler = null;
_deferredScheduler = null;
_processTimeSource = null;
_processRealtimeTimeSource = null;
_processIgnorePauseTimeSource = null;
_processIgnorePauseRealtimeTimeSource = null;
_physicsTimeSource = null;
_physicsRealtimeTimeSource = null;
_deferredTimeSource = null;
_deferredRealtimeTimeSource = null;
_frameCounter = 0;
_instanceId = 1;
}
/// <summary>
/// 提供测试默认使用的稳定帧增量。
/// </summary>
/// <returns>固定的 60 FPS 增量。</returns>
private static double DefaultDeltaProvider()
{
return 1.0 / 60.0;
}
}

View File

@ -20,8 +20,8 @@ public partial class Timing : Node
private static readonly Timing?[] ActiveInstances = new Timing?[16]; private static readonly Timing?[] ActiveInstances = new Timing?[16];
private static Timing? _instance; private static Timing? _instance;
private readonly Dictionary<CoroutineHandle, OwnedCoroutineRegistration> _ownedCoroutineRegistrations = new(); private Dictionary<CoroutineHandle, OwnedCoroutineRegistration> _ownedCoroutineRegistrations = new();
private readonly Dictionary<ulong, HashSet<CoroutineHandle>> _ownedCoroutinesByNode = new(); private Dictionary<ulong, HashSet<CoroutineHandle>> _ownedCoroutinesByNode = new();
private GodotTimeSource? _deferredRealtimeTimeSource; private GodotTimeSource? _deferredRealtimeTimeSource;
private CoroutineScheduler? _deferredScheduler; private CoroutineScheduler? _deferredScheduler;
private GodotTimeSource? _deferredTimeSource; private GodotTimeSource? _deferredTimeSource;
@ -185,15 +185,19 @@ public partial class Timing : Node
ActiveInstances[_instanceId] = null; ActiveInstances[_instanceId] = null;
} }
CleanupInstanceIfNecessary(); CleanupInstanceIfNecessary(this);
} }
/// <summary> /// <summary>
/// 清理单例引用 /// 仅在当前实例仍持有共享单例引用时清理它,避免多宿主场景误清其他实例
/// </summary> /// </summary>
private static void CleanupInstanceIfNecessary() /// <param name="instance">正在退出生命周期的实例。</param>
private static void CleanupInstanceIfNecessary(Timing instance)
{ {
_instance = null; if (ReferenceEquals(_instance, instance))
{
_instance = null;
}
} }
/// <summary> /// <summary>
@ -901,4 +905,4 @@ public partial class Timing : Node
} }
#endregion #endregion
} }

View File

@ -7,32 +7,47 @@
## 当前恢复点 ## 当前恢复点
- 恢复点编号:`COROUTINE-OPTIMIZATION-RP-001` - 恢复点编号:`COROUTINE-OPTIMIZATION-RP-002`
- 当前阶段:`Phase 1` - 当前阶段:`Phase 4`
- 当前焦点: - 当前焦点:
- 已将 worktree-root 遗留的 `local-plan/` 迁入 `ai-plan/public/coroutine-optimization/`active 入口只保留当前恢复信息 - 已为 `Timing` 补齐纯托管测试宿主入口,允许在 `dotnet test` 下验证 Godot 协程宿主阶段语义,而不依赖原生 `Node` 构造
- 基于早期计划中已经完成的第一轮实现,重新收敛后续切入点,避免把语义命名、宿主集成、测试扩面和文档清理混成一次大任务 - 已补充 `GFramework.Godot.Tests/Coroutine/TimingTests.cs`锁定暂停、segment 路由和阶段型等待指令的回归覆盖
- 明确记录“旧计划没有 durable trace只有 todo 基线”,后续恢复时先读 active 入口,再按需展开 archive - 已根据 PR #259 的最新 CodeRabbit review 收口测试宿主清理对称性,并将 `TimingTests` 固定为非并行执行
- 已根据 PR #259`MegaLinter analysis: Success with warnings` 结果修复 `dotnet format` workspace 歧义,并增强 PR review skill 以提取此类 CI warning
- 下一轮优先补“仍需真实场景树参与”的归属协程 / 退树语义或转入文档迁移收口不再回到“Godot 宿主没有自动化回归”的旧状态
## 当前状态摘要 ## 当前状态摘要
- Core 协程第一轮语义收拢已完成,包括真实时间源、执行阶段与阶段型等待的基础行为调整 - Core 协程第一轮语义收拢已完成,包括真实时间源、执行阶段与阶段型等待的基础行为调整
- 调度器第一版控制与可观测能力已落地,包括完成状态、等待完成、快照查询和完成事件 - 调度器第一版控制与可观测能力已落地,包括完成状态、等待完成、快照查询和完成事件
- Godot 宿主第一版接入已落地,包括分段时间源、节点归属协程入口与退树终止语义 - Godot 宿主第一版接入已落地,包括分段时间源、节点归属协程入口与退树终止语义
- Core 与 Godot 两侧已经具备一轮基础测试与文档更新,但更贴近运行时的集成验证、兼容性说明和迁移对照仍未收口 - Core 与 Godot 两侧已经具备一轮基础测试与文档更新;其中 Godot 侧现已补齐 `Timing` 的 pause / segment / stage-wait 自动化回归
- 更贴近真实场景树的节点归属、退树与 `queue_free` 集成验证,以及迁移对照文档仍未收口
## 当前活跃事实 ## 当前活跃事实
- 本主题的详细历史不是从已有 trace 迁入,而是由旧 `local-plan/todos/coroutine/*.md` 整合出的计划基线 - 本主题的详细历史不是从已有 trace 迁入,而是由旧 `local-plan/todos/coroutine/*.md` 整合出的计划基线
- `RP-001` 的详细工作流拆分、验收标准和缺失 trace 说明已归档到主题内 `archive/` - `RP-001` 的详细工作流拆分、验收标准和缺失 trace 说明已归档到主题内 `archive/`
- 当前工作树分支 `feat/coroutine-optimization` 已在 `ai-plan/public/README.md` 建立 topic 映射 - 当前工作树分支 `feat/coroutine-optimization` 已在 `ai-plan/public/README.md` 建立 topic 映射
- `RP-002` 已在 `GFramework.Godot` 内新增仅供测试使用的 `Timing` 纯托管宿主入口,不改公开 API
- `RP-002` 已新增 `TimingTests`,覆盖:
- 暂停时 `Process` / `ProcessIgnorePause` 的差异
- `Process` / `PhysicsProcess` / `DeferredProcess` 的推进边界
- `WaitForFixedUpdate``WaitForEndOfFrame` 的阶段型等待语义
- 针对 PR #259 的最新未解决 review 线程,已补充两项收口:
- `TimingTests` 已添加 `[NonParallelizable]`,避免共享静态实例槽位在 NUnit 并行执行时互相污染
- `Timing` 的测试清理与运行时退树清理现仅在当前实例持有共享 `_instance` 引用时才会清空单例状态
- 针对 PR #259`MegaLinter` warning已补充两项收口
- `.mega-linter.yml` 现为 `CSHARP_DOTNET_FORMAT_ARGUMENTS` 显式指定 `GFramework.sln`,避免仓库根目录同时存在 `*.sln``*.csproj` 时触发 workspace 歧义
- `.codex/skills/gframework-pr-review/` 现会抓取并解析 `github-actions[bot]` 发布的 `MegaLinter analysis: Success with warnings` comment默认把其中的 detailed issues 视为待验证输入
- `dotnet test GFramework.Godot.Tests/GFramework.Godot.Tests.csproj -c Release --no-restore` 当前通过,合计 `58` 个测试
## 当前风险 ## 当前风险
- 语义兼容性风险:`Delay``WaitForSecondsScaled``WaitForNextFrame``WaitOneFrame` 等命名与行为若继续调整,可能影响既有调用认知 - 语义兼容性风险:`Delay``WaitForSecondsScaled``WaitForNextFrame``WaitOneFrame` 等命名与行为若继续调整,可能影响既有调用认知
- 缓解措施:下一轮只先挑一个语义面收敛,并同步补足迁移说明与宿主前提文档 - 缓解措施:下一轮只先挑一个语义面收敛,并同步补足迁移说明与宿主前提文档
- 宿主验证缺口风险Godot 节点归属、退树、暂停与各 segment 差异仍缺少更贴近运行时的自动化回归 - 宿主验证缺口风险Godot 节点归属、退树、`queue_free` 与真实场景树回调仍缺少更贴近运行时的自动化回归
- 缓解措施:优先规划 Godot 集成测试宿主,再决定是否扩展更多运行时诊断 API - 缓解措施:下一轮仅补真实场景树相关宿主验证;已完成的 `Timing` 纯托管语义测试不再重复规划
- 历史信息稀疏风险:旧计划没有同步保留当时的执行 trace 与完整验证记录 - 历史信息稀疏风险:旧计划没有同步保留当时的执行 trace 与完整验证记录
- 缓解措施active 文档只保留当前结论;需要历史语义时回看 archive并明确哪些内容是从早期 todo 推导出的基线 - 缓解措施active 文档只保留当前结论;需要历史语义时回看 archive并明确哪些内容是从早期 todo 推导出的基线
@ -45,9 +60,21 @@
- 旧 `local-plan` 的五份 coroutine todo 已整合进主题内历史归档,不再作为 worktree-root durable recovery 入口保留 - 旧 `local-plan` 的五份 coroutine todo 已整合进主题内历史归档,不再作为 worktree-root durable recovery 入口保留
- active 跟踪文件只保留当前恢复点、活跃事实、风险与下一步,避免把更早期计划直接平移成新的追加式日志 - active 跟踪文件只保留当前恢复点、活跃事实、风险与下一步,避免把更早期计划直接平移成新的追加式日志
- `dotnet test GFramework.Godot.Tests/GFramework.Godot.Tests.csproj -c Release --filter "FullyQualifiedName~TimingTests" --no-restore`
- 结果:通过
- 备注:新增 `TimingTests``5` 个测试全部通过
- `dotnet test GFramework.Godot.Tests/GFramework.Godot.Tests.csproj -c Release --no-restore`
- 结果:通过
- 备注Godot 测试项目共 `58` 个测试全部通过
- `dotnet test GFramework.Godot.Tests/GFramework.Godot.Tests.csproj -c Release --filter "FullyQualifiedName~TimingTests" --no-restore`
- 结果:通过
- 备注:针对 PR #259 review 修复后的 `TimingTests``5` 个测试全部通过
- `dotnet build GFramework.Godot.Tests/GFramework.Godot.Tests.csproj -c Release --no-restore`
- 结果:通过
- 备注CI/MegaLinter 配置与 PR review skill 更新后,目标测试项目仍保持 `0 warning / 0 error`
## 下一步 ## 下一步
1. 若继续该主题,先在 `Core Semantics``Control And Observability``Godot Runtime Integration``Tests And Regressions``Docs And Migration` 中只选一个切入点推进 1. 若继续补验证,优先只做真实场景树相关的节点归属 / 退树 / `queue_free` 回归,不再重新设计 `Timing` 纯托管宿主
2. 若优先补验证,先规划 Godot 集成测试宿主与节点归属/退树/暂停场景,再扩运行时诊断 API 2. 当前 PR 合并前可直接回到 GitHub 确认最新 push 是否已消除 `MegaLinter analysis` warning并顺手处理 review 线程的回复与 resolve
3. 若优先补文档与迁移说明,先清理其余 `StartCoroutine()/StopCoroutine()` 残留,再为阶段等待和新入口补统一对照说明 3. 若转入文档收口,优先清理其余 `StartCoroutine()/StopCoroutine()` 残留,并补 Godot 新入口与阶段等待的迁移对照

View File

@ -32,3 +32,39 @@
1. 后续若继续 coroutine 主题,只从 `ai-plan/public/coroutine-optimization/` 进入,不再恢复 `local-plan/` 1. 后续若继续 coroutine 主题,只从 `ai-plan/public/coroutine-optimization/` 进入,不再恢复 `local-plan/`
2. 下一轮只选择一个主切入点推进,避免语义、宿主、测试和文档扩面同时发生 2. 下一轮只选择一个主切入点推进,避免语义、宿主、测试和文档扩面同时发生
3. 若 active 入口后续积累多轮已完成且已验证阶段,再按同一模式迁入该主题自己的 `archive/` 3. 若 active 入口后续积累多轮已完成且已验证阶段,再按同一模式迁入该主题自己的 `archive/`
## 2026-04-20
### 阶段Godot 宿主回归覆盖补齐RP-002
- 选择只推进 `Tests And Regressions` 切面,不同时改动协程语义与迁移文档
- 新增 `GFramework.Godot/Coroutine/Timing.Testing.cs`,为 `Timing` 提供仅供测试使用的纯托管初始化与帧推进入口
- 新增 `GFramework.Godot.Tests/Coroutine/TimingTests.cs`,覆盖:
- 暂停时 `Process``ProcessIgnorePause` 的推进差异
- `Process` / `PhysicsProcess` / `DeferredProcess` 的执行边界与顺序
- `WaitForFixedUpdate``WaitForEndOfFrame` 的阶段型等待语义
- 关键决策:在 `dotnet test` 宿主中不直接运行 `Timing : Node` 的原生构造,而是使用未初始化对象配合测试入口补齐纯托管字段
- 原因:直接构造 `Godot.Node` 派生类型会导致 VSTest test host 崩溃,无法作为稳定回归路径
- 约束:当前测试覆盖的是宿主调度语义,不覆盖真实场景树信号、节点归属与退树回调
- 为支持上述测试入口,将 `Timing` 的节点归属字典从只读字段调整为可在测试初始化阶段重建的私有字段,未改动任何公共 API
- 完成验证:
- `dotnet test GFramework.Godot.Tests/GFramework.Godot.Tests.csproj -c Release --filter "FullyQualifiedName~TimingTests" --no-restore`
- `dotnet test GFramework.Godot.Tests/GFramework.Godot.Tests.csproj -c Release --no-restore`
- 同日根据 PR #259 的最新 unresolved CodeRabbit review 继续收口:
- 在 `GFramework.Godot.Tests/Coroutine/TimingTests.cs` 为 fixture 添加 `[NonParallelizable]`,避免静态实例槽位在 NUnit 并行执行时互相污染
- 将 `Timing``_instance` 清理改为“仅当当前实例仍持有共享单例引用时才执行”,同时覆盖运行时 `_ExitTree()` 与测试入口 `DisposeForTests()`
- 额外完成验证:
- `dotnet test GFramework.Godot.Tests/GFramework.Godot.Tests.csproj -c Release --filter "FullyQualifiedName~TimingTests" --no-restore`
- 同日继续收口 PR #259 页面上的 `MegaLinter analysis: Success with warnings`
- 确认 detailed issue 实际不是格式差异,而是 `dotnet format` 在仓库根目录同时发现 `GFramework.sln``GFramework.csproj` 后因未指定 workspace 直接抛异常
- 更新 `.mega-linter.yml`,为 `CSHARP_DOTNET_FORMAT_ARGUMENTS` 显式指定 `GFramework.sln`
- 更新 `.codex/skills/gframework-pr-review/SKILL.md``scripts/fetch_current_pr_review.py`,使 skill 默认抓取并输出 `github-actions[bot]` 的 MegaLinter comments 和 detailed issues
- 额外完成验证:
- `python3 -c "from pathlib import Path; compile(Path('.codex/skills/gframework-pr-review/scripts/fetch_current_pr_review.py').read_text(encoding='utf-8'), '.codex/skills/gframework-pr-review/scripts/fetch_current_pr_review.py', 'exec'); print('syntax-ok')"`
- `python3 .codex/skills/gframework-pr-review/scripts/fetch_current_pr_review.py --branch feat/coroutine-optimization --format json`
- `dotnet build GFramework.Godot.Tests/GFramework.Godot.Tests.csproj -c Release --no-restore`
### 下一步
1. 若继续补验证,优先规划真实场景树参与的节点归属 / 退树 / `queue_free` 测试宿主
2. 若转入文档收口,优先清理仍引用 `StartCoroutine()/StopCoroutine()` 的教程残留,并补迁移对照