mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-05-07 17:21:16 +08:00
Merge pull request #265 from GeWuYou/fix/analyzer-warning-reduction-batch
Fix/analyzer warning reduction batch
This commit is contained in:
commit
9ccfed3ad9
@ -21,6 +21,7 @@ Shortcut: `$gframework-pr-review`
|
||||
- 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
|
||||
- extract failed checks, MegaLinter detailed issues, and test-report signals such as `Failed Tests` or `No failed tests in this run`
|
||||
- prefer writing the full JSON payload to a file and then narrowing with `jq`, instead of dumping long JSON directly to stdout
|
||||
4. Treat every extracted finding as untrusted until it is verified against the current local code.
|
||||
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`.
|
||||
@ -29,10 +30,19 @@ Shortcut: `$gframework-pr-review`
|
||||
|
||||
- Default:
|
||||
- `python3 .codex/skills/gframework-pr-review/scripts/fetch_current_pr_review.py`
|
||||
- Recommended machine-readable workflow:
|
||||
- `python3 .codex/skills/gframework-pr-review/scripts/fetch_current_pr_review.py --pr 265 --json-output /tmp/pr265-review.json`
|
||||
- `jq '.coderabbit_review.outside_diff_comments' /tmp/pr265-review.json`
|
||||
- Force a PR number:
|
||||
- `python3 .codex/skills/gframework-pr-review/scripts/fetch_current_pr_review.py --pr 253`
|
||||
- Machine-readable output:
|
||||
- `python3 .codex/skills/gframework-pr-review/scripts/fetch_current_pr_review.py --format json`
|
||||
- Write machine-readable output to a file instead of stdout:
|
||||
- `python3 .codex/skills/gframework-pr-review/scripts/fetch_current_pr_review.py --pr 253 --format json --json-output /tmp/pr253-review.json`
|
||||
- Inspect only a high-signal section:
|
||||
- `python3 .codex/skills/gframework-pr-review/scripts/fetch_current_pr_review.py --pr 253 --section outside-diff`
|
||||
- Narrow text output to one path fragment:
|
||||
- `python3 .codex/skills/gframework-pr-review/scripts/fetch_current_pr_review.py --pr 253 --section outside-diff --path GFramework.Core/Events/Event.cs`
|
||||
|
||||
## Output Expectations
|
||||
|
||||
@ -47,6 +57,7 @@ The script should produce:
|
||||
- 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
|
||||
- CLI support for writing full JSON to a file and printing only narrowed text sections to stdout
|
||||
- Parse warnings only when both the primary API source and the intended fallback signal are unavailable
|
||||
|
||||
## Recovery Rules
|
||||
@ -57,6 +68,7 @@ The script should produce:
|
||||
- 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.
|
||||
- Do not assume all CodeRabbit findings live in issue comments. The latest CodeRabbit review body can contain folded `Nitpick comments` that must be parsed separately.
|
||||
- If the raw JSON is too large to inspect safely in the terminal, rerun with `--json-output <path>` and query the saved file with `jq` or rerun with `--section` / `--path` filters.
|
||||
|
||||
## Example Triggers
|
||||
|
||||
|
||||
@ -10,6 +10,7 @@ import argparse
|
||||
import html
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
@ -29,6 +30,17 @@ 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)
|
||||
DEFAULT_REQUEST_TIMEOUT_SECONDS = 60
|
||||
REQUEST_TIMEOUT_ENVIRONMENT_KEY = "GFRAMEWORK_PR_REVIEW_TIMEOUT_SECONDS"
|
||||
DISPLAY_SECTION_CHOICES = (
|
||||
"pr",
|
||||
"failed-checks",
|
||||
"actionable",
|
||||
"outside-diff",
|
||||
"nitpick",
|
||||
"open-threads",
|
||||
"megalinter",
|
||||
"tests",
|
||||
"warnings",
|
||||
)
|
||||
|
||||
|
||||
def resolve_git_command() -> str:
|
||||
@ -153,6 +165,14 @@ def collapse_whitespace(text: str) -> str:
|
||||
return re.sub(r"\s+", " ", text).strip()
|
||||
|
||||
|
||||
def truncate_text(text: str, max_length: int) -> str:
|
||||
collapsed = collapse_whitespace(text)
|
||||
if max_length <= 0 or len(collapsed) <= max_length:
|
||||
return collapsed
|
||||
|
||||
return collapsed[: max_length - 3].rstrip() + "..."
|
||||
|
||||
|
||||
def strip_tags(text: str) -> str:
|
||||
return collapse_whitespace(re.sub(r"<[^>]+>", " ", text))
|
||||
|
||||
@ -710,64 +730,142 @@ def build_result(pr_number: int, branch: str) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def format_text(result: dict[str, Any]) -> str:
|
||||
def write_json_output(result: dict[str, Any], output_path: str) -> str:
|
||||
destination_path = Path(output_path).expanduser()
|
||||
destination_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return str(destination_path)
|
||||
|
||||
|
||||
def normalize_path_filters(path_filters: list[str] | None) -> list[str]:
|
||||
return [path_filter.replace("\\", "/") for path_filter in (path_filters or []) if path_filter.strip()]
|
||||
|
||||
|
||||
def path_matches_filters(path: str, normalized_path_filters: list[str]) -> bool:
|
||||
if not normalized_path_filters:
|
||||
return True
|
||||
|
||||
normalized_path = path.replace("\\", "/")
|
||||
return any(path_filter in normalized_path for path_filter in normalized_path_filters)
|
||||
|
||||
|
||||
def filter_comments_by_path(
|
||||
comments: list[dict[str, Any]],
|
||||
normalized_path_filters: list[str],
|
||||
) -> list[dict[str, Any]]:
|
||||
return [comment for comment in comments if path_matches_filters(str(comment.get("path") or ""), normalized_path_filters)]
|
||||
|
||||
|
||||
def filter_threads_by_path(
|
||||
threads: list[dict[str, Any]],
|
||||
normalized_path_filters: list[str],
|
||||
) -> list[dict[str, Any]]:
|
||||
return [thread for thread in threads if path_matches_filters(str(thread.get("path") or ""), normalized_path_filters)]
|
||||
|
||||
|
||||
def format_text(
|
||||
result: dict[str, Any],
|
||||
*,
|
||||
sections: list[str] | None = None,
|
||||
path_filters: list[str] | None = None,
|
||||
max_description_length: int = 400,
|
||||
json_output_path: str | None = None,
|
||||
) -> str:
|
||||
lines: list[str] = []
|
||||
selected_sections = set(sections or DISPLAY_SECTION_CHOICES)
|
||||
normalized_path_filters = normalize_path_filters(path_filters)
|
||||
pr = result["pull_request"]
|
||||
lines.append(f"PR #{pr['number']}: {pr['title']}")
|
||||
lines.append(f"State: {pr['state']}")
|
||||
lines.append(f"Branch: {pr['head_branch']} -> {pr['base_branch']}")
|
||||
lines.append(f"URL: {pr['url']}")
|
||||
if "pr" in selected_sections:
|
||||
lines.append(f"PR #{pr['number']}: {pr['title']}")
|
||||
lines.append(f"State: {pr['state']}")
|
||||
lines.append(f"Branch: {pr['head_branch']} -> {pr['base_branch']}")
|
||||
lines.append(f"URL: {pr['url']}")
|
||||
|
||||
failed_checks = result["coderabbit_summary"].get("failed_checks", [])
|
||||
lines.append("")
|
||||
lines.append(f"Failed checks: {len(failed_checks)}")
|
||||
for check in failed_checks:
|
||||
lines.append(f"- {check['name']}: {check['status']}")
|
||||
lines.append(f" Explanation: {check['explanation']}")
|
||||
lines.append(f" Resolution: {check['resolution']}")
|
||||
if "failed-checks" in selected_sections:
|
||||
lines.append("")
|
||||
lines.append(f"Failed checks: {len(failed_checks)}")
|
||||
for check in failed_checks:
|
||||
lines.append(f"- {check['name']}: {check['status']}")
|
||||
lines.append(f" Explanation: {truncate_text(check['explanation'], max_description_length)}")
|
||||
lines.append(f" Resolution: {truncate_text(check['resolution'], max_description_length)}")
|
||||
|
||||
coderabbit_comments = result.get("coderabbit_comments", {})
|
||||
review_feedback = result.get("coderabbit_review", {})
|
||||
comments = coderabbit_comments.get("comments", [])
|
||||
visible_comments = filter_comments_by_path(comments, normalized_path_filters)
|
||||
actionable_count = review_feedback.get("actionable_count") or coderabbit_comments.get("count") or len(comments)
|
||||
lines.append("")
|
||||
lines.append(f"CodeRabbit actionable comments: {actionable_count}")
|
||||
for comment in comments:
|
||||
lines.append(f"- {comment['path']} {comment['range']}".rstrip())
|
||||
if comment["title"]:
|
||||
lines.append(f" Title: {comment['title']}")
|
||||
if comment["description"]:
|
||||
lines.append(f" Description: {comment['description']}")
|
||||
if actionable_count and not comments:
|
||||
lines.append(" Details: see latest-commit review threads below.")
|
||||
if "actionable" in selected_sections:
|
||||
lines.append("")
|
||||
lines.append(
|
||||
f"CodeRabbit actionable comments: {actionable_count} total"
|
||||
+ (
|
||||
f", {len(visible_comments)} shown after path filter"
|
||||
if normalized_path_filters
|
||||
else ""
|
||||
)
|
||||
)
|
||||
for comment in visible_comments:
|
||||
lines.append(f"- {comment['path']} {comment['range']}".rstrip())
|
||||
if comment["title"]:
|
||||
lines.append(f" Title: {truncate_text(comment['title'], max_description_length)}")
|
||||
if comment["description"]:
|
||||
lines.append(f" Description: {truncate_text(comment['description'], max_description_length)}")
|
||||
if actionable_count and not visible_comments:
|
||||
lines.append(" Details: no actionable comments matched the current path filter.")
|
||||
elif actionable_count and not comments:
|
||||
lines.append(" Details: see latest-commit review threads below.")
|
||||
|
||||
outside_diff_comments = review_feedback.get("outside_diff_comments", [])
|
||||
visible_outside_diff_comments = filter_comments_by_path(outside_diff_comments, normalized_path_filters)
|
||||
outside_diff_count = review_feedback.get("outside_diff_count") or len(outside_diff_comments)
|
||||
lines.append("")
|
||||
lines.append(f"CodeRabbit outside-diff comments: {outside_diff_count} declared, {len(outside_diff_comments)} parsed")
|
||||
for comment in outside_diff_comments:
|
||||
lines.append(f"- {comment['path']} {comment['range']}".rstrip())
|
||||
if comment["title"]:
|
||||
lines.append(f" Title: {comment['title']}")
|
||||
if comment["description"]:
|
||||
lines.append(f" Description: {comment['description']}")
|
||||
if "outside-diff" in selected_sections:
|
||||
lines.append("")
|
||||
lines.append(
|
||||
f"CodeRabbit outside-diff comments: {outside_diff_count} declared, {len(outside_diff_comments)} parsed"
|
||||
+ (
|
||||
f", {len(visible_outside_diff_comments)} shown after path filter"
|
||||
if normalized_path_filters
|
||||
else ""
|
||||
)
|
||||
)
|
||||
for comment in visible_outside_diff_comments:
|
||||
lines.append(f"- {comment['path']} {comment['range']}".rstrip())
|
||||
if comment["title"]:
|
||||
lines.append(f" Title: {truncate_text(comment['title'], max_description_length)}")
|
||||
if comment["description"]:
|
||||
lines.append(f" Description: {truncate_text(comment['description'], max_description_length)}")
|
||||
if outside_diff_comments and not visible_outside_diff_comments:
|
||||
lines.append(" Details: no outside-diff comments matched the current path filter.")
|
||||
|
||||
nitpick_comments = review_feedback.get("nitpick_comments", [])
|
||||
visible_nitpick_comments = filter_comments_by_path(nitpick_comments, normalized_path_filters)
|
||||
nitpick_count = review_feedback.get("nitpick_count") or len(nitpick_comments)
|
||||
lines.append("")
|
||||
lines.append(f"CodeRabbit nitpick comments: {nitpick_count} declared, {len(nitpick_comments)} parsed")
|
||||
for comment in nitpick_comments:
|
||||
lines.append(f"- {comment['path']} {comment['range']}".rstrip())
|
||||
if comment["title"]:
|
||||
lines.append(f" Title: {comment['title']}")
|
||||
if comment["description"]:
|
||||
lines.append(f" Description: {comment['description']}")
|
||||
if "nitpick" in selected_sections:
|
||||
lines.append("")
|
||||
lines.append(
|
||||
f"CodeRabbit nitpick comments: {nitpick_count} declared, {len(nitpick_comments)} parsed"
|
||||
+ (
|
||||
f", {len(visible_nitpick_comments)} shown after path filter"
|
||||
if normalized_path_filters
|
||||
else ""
|
||||
)
|
||||
)
|
||||
for comment in visible_nitpick_comments:
|
||||
lines.append(f"- {comment['path']} {comment['range']}".rstrip())
|
||||
if comment["title"]:
|
||||
lines.append(f" Title: {truncate_text(comment['title'], max_description_length)}")
|
||||
if comment["description"]:
|
||||
lines.append(f" Description: {truncate_text(comment['description'], max_description_length)}")
|
||||
if nitpick_comments and not visible_nitpick_comments:
|
||||
lines.append(" Details: no nitpick comments matched the current path filter.")
|
||||
|
||||
latest_commit_review = result.get("latest_commit_review", {})
|
||||
latest_commit = latest_commit_review.get("latest_commit", {})
|
||||
latest_review = latest_commit_review.get("latest_review", {})
|
||||
open_threads = latest_commit_review.get("open_threads", [])
|
||||
if latest_commit:
|
||||
visible_open_threads = filter_threads_by_path(open_threads, normalized_path_filters)
|
||||
if latest_commit and "open-threads" in selected_sections:
|
||||
lines.append("")
|
||||
lines.append(f"Latest reviewed commit: {latest_commit.get('sha', '')}")
|
||||
if latest_review:
|
||||
@ -780,23 +878,32 @@ def format_text(result: dict[str, Any]) -> str:
|
||||
lines.append(
|
||||
"Latest commit review threads: "
|
||||
f"{len(latest_commit_review.get('threads', []))} total, {len(open_threads)} open"
|
||||
+ (
|
||||
f", {len(visible_open_threads)} shown after path filter"
|
||||
if normalized_path_filters
|
||||
else ""
|
||||
)
|
||||
)
|
||||
for thread in open_threads:
|
||||
for thread in visible_open_threads:
|
||||
root_comment = thread["root_comment"]
|
||||
latest_comment = thread["latest_comment"]
|
||||
lines.append(f"- {thread['path']}:{thread['line']}")
|
||||
lines.append(f" Root by {root_comment['user']}: {collapse_whitespace(root_comment['body'])}")
|
||||
lines.append(f" Root by {root_comment['user']}: {truncate_text(root_comment['body'], max_description_length)}")
|
||||
if latest_comment["id"] != root_comment["id"]:
|
||||
lines.append(f" Latest by {latest_comment['user']}: {collapse_whitespace(latest_comment['body'])}")
|
||||
lines.append(
|
||||
f" Latest by {latest_comment['user']}: {truncate_text(latest_comment['body'], max_description_length)}"
|
||||
)
|
||||
if contains_visible_addressed_commit_text(root_comment["body"]) or contains_visible_addressed_commit_text(
|
||||
latest_comment["body"]
|
||||
):
|
||||
lines.append(
|
||||
" Note: thread is still open; treat the visible 'Addressed in commit ...' text as unverified until local code matches."
|
||||
)
|
||||
if open_threads and not visible_open_threads:
|
||||
lines.append(" Details: no open threads matched the current path filter.")
|
||||
|
||||
megalinter_report = result.get("megalinter_report", {})
|
||||
if megalinter_report:
|
||||
if megalinter_report and "megalinter" in selected_sections:
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"MegaLinter: "
|
||||
@ -818,32 +925,37 @@ def format_text(result: dict[str, Any]) -> str:
|
||||
|
||||
for issue in megalinter_report.get("detailed_issues", []):
|
||||
lines.append(f"- Detailed issue: {issue['summary']}")
|
||||
lines.append(f" {collapse_whitespace(issue['details'])}")
|
||||
lines.append(f" {truncate_text(issue['details'], max_description_length)}")
|
||||
|
||||
lines.append("")
|
||||
lines.append(f"Test reports: {len(result['test_reports'])}")
|
||||
for index, report in enumerate(result["test_reports"], start=1):
|
||||
stats = report.get("stats", {})
|
||||
if stats:
|
||||
lines.append(
|
||||
f"- Report {index}: tests={stats.get('tests')} passed={stats.get('passed')} "
|
||||
f"failed={stats.get('failed')} skipped={stats.get('skipped')} flaky={stats.get('flaky')} "
|
||||
f"duration={stats.get('duration')}"
|
||||
)
|
||||
else:
|
||||
lines.append(f"- Report {index}: no structured test stats parsed")
|
||||
if "tests" in selected_sections:
|
||||
lines.append("")
|
||||
lines.append(f"Test reports: {len(result['test_reports'])}")
|
||||
for index, report in enumerate(result["test_reports"], start=1):
|
||||
stats = report.get("stats", {})
|
||||
if stats:
|
||||
lines.append(
|
||||
f"- Report {index}: tests={stats.get('tests')} passed={stats.get('passed')} "
|
||||
f"failed={stats.get('failed')} skipped={stats.get('skipped')} flaky={stats.get('flaky')} "
|
||||
f"duration={stats.get('duration')}"
|
||||
)
|
||||
else:
|
||||
lines.append(f"- Report {index}: no structured test stats parsed")
|
||||
|
||||
if report["has_failed_tests"]:
|
||||
for failed_test in report["failed_tests"]:
|
||||
lines.append(f" Failed test: {failed_test}")
|
||||
else:
|
||||
lines.append(" Failed tests: none reported")
|
||||
if report["has_failed_tests"]:
|
||||
for failed_test in report["failed_tests"]:
|
||||
lines.append(f" Failed test: {truncate_text(failed_test, max_description_length)}")
|
||||
else:
|
||||
lines.append(" Failed tests: none reported")
|
||||
|
||||
if result["parse_warnings"]:
|
||||
if result["parse_warnings"] and "warnings" in selected_sections:
|
||||
lines.append("")
|
||||
lines.append("Warnings:")
|
||||
for warning in result["parse_warnings"]:
|
||||
lines.append(f"- {warning}")
|
||||
lines.append(f"- {truncate_text(warning, max_description_length)}")
|
||||
|
||||
if json_output_path:
|
||||
lines.append("")
|
||||
lines.append(f"Full JSON written to: {json_output_path}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@ -853,6 +965,27 @@ def parse_args() -> argparse.Namespace:
|
||||
parser.add_argument("--branch", help="Override the current branch name.")
|
||||
parser.add_argument("--pr", type=int, help="Fetch a specific PR number instead of resolving from branch.")
|
||||
parser.add_argument("--format", choices=("text", "json"), default="text")
|
||||
parser.add_argument(
|
||||
"--json-output",
|
||||
help="Write the full JSON result to a file. When used with --format text, stdout stays concise and points to the file.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--section",
|
||||
action="append",
|
||||
choices=DISPLAY_SECTION_CHOICES,
|
||||
help="Limit text output to specific sections. Can be passed multiple times.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--path",
|
||||
action="append",
|
||||
help="Only show comments and review threads whose path contains this fragment. Can be passed multiple times.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-description-length",
|
||||
type=int,
|
||||
default=400,
|
||||
help="Truncate long text bodies in text output to this many characters.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
@ -866,12 +999,27 @@ def main() -> None:
|
||||
pr_number = resolve_pr_number(branch)
|
||||
|
||||
result = build_result(pr_number, branch)
|
||||
json_output_path: str | None = None
|
||||
if args.json_output:
|
||||
json_output_path = write_json_output(result, args.json_output)
|
||||
|
||||
if args.format == "json":
|
||||
if json_output_path:
|
||||
print(json_output_path)
|
||||
return
|
||||
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return
|
||||
|
||||
print(format_text(result))
|
||||
print(
|
||||
format_text(
|
||||
result,
|
||||
sections=args.section,
|
||||
path_filters=args.path,
|
||||
max_description_length=args.max_description_length,
|
||||
json_output_path=json_output_path,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@ -345,6 +345,20 @@ public class CoroutineSchedulerTests
|
||||
Assert.That(_scheduler.ActiveCoroutineCount, Is.EqualTo(10));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证调度器在零初始容量下会在首次启动协程时自动扩容,而不是写入越界。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void Run_Should_Grow_From_Zero_Initial_Capacity()
|
||||
{
|
||||
var scheduler = new CoroutineScheduler(new TestTimeSource(), initialCapacity: 0);
|
||||
|
||||
var handle = scheduler.Run(CreateYieldingCoroutine(new WaitOneFrame()));
|
||||
|
||||
Assert.That(handle.IsValid, Is.True);
|
||||
Assert.That(scheduler.ActiveCoroutineCount, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证协程调度器应该使用提供的时间源
|
||||
/// </summary>
|
||||
@ -563,4 +577,4 @@ public class TestTimeSource : ITimeSource
|
||||
DeltaTime = 0.1;
|
||||
CurrentTime += DeltaTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -124,6 +124,24 @@ public class EventTests
|
||||
Assert.That(values, Does.Contain(10));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 测试单参数事件的监听器计数只统计真实注册的处理器。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void EventT_GetListenerCount_Should_Exclude_Placeholder_Handler()
|
||||
{
|
||||
Assert.That(_eventInt.GetListenerCount(), Is.EqualTo(0));
|
||||
|
||||
Action<int> handler = _ => { };
|
||||
_eventInt.Register(handler);
|
||||
|
||||
Assert.That(_eventInt.GetListenerCount(), Is.EqualTo(1));
|
||||
|
||||
_eventInt.UnRegister(handler);
|
||||
|
||||
Assert.That(_eventInt.GetListenerCount(), Is.EqualTo(0));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 测试带两个泛型参数的事件注册功能是否正确添加处理器
|
||||
/// </summary>
|
||||
@ -161,4 +179,22 @@ public class EventTests
|
||||
_eventIntString.Trigger(2, "b");
|
||||
Assert.That(count, Is.EqualTo(1));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 测试双参数事件的监听器计数只统计真实注册的处理器。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void EventTTK_GetListenerCount_Should_Exclude_Placeholder_Handler()
|
||||
{
|
||||
Assert.That(_eventIntString.GetListenerCount(), Is.EqualTo(0));
|
||||
|
||||
Action<int, string> handler = (_, _) => { };
|
||||
_eventIntString.Register(handler);
|
||||
|
||||
Assert.That(_eventIntString.GetListenerCount(), Is.EqualTo(1));
|
||||
|
||||
_eventIntString.UnRegister(handler);
|
||||
|
||||
Assert.That(_eventIntString.GetListenerCount(), Is.EqualTo(0));
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
using System.Collections;
|
||||
using System.Reflection;
|
||||
|
||||
namespace GFramework.Core.Tests.StateManagement;
|
||||
|
||||
/// <summary>
|
||||
@ -384,6 +387,32 @@ public class StoreTests
|
||||
Assert.That(logs, Is.EqualTo(new[] { "dynamic:before", "dynamic:after" }));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 测试当 dispatch 作用域在快照阶段抛出异常时,Store 不会残留“正在分发”标记而锁死后续调用。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void Dispatch_Should_Reset_Dispatching_Flag_When_Snapshot_Creation_Throws()
|
||||
{
|
||||
var store = new Store<CounterState>(
|
||||
new CounterState(0, "Player"),
|
||||
actionMatchingMode: StoreActionMatchingMode.IncludeAssignableTypes);
|
||||
|
||||
store.RegisterReducer<IncrementAction>((state, action) => state with { Count = state.Count + action.Amount });
|
||||
|
||||
var reducers = GetReducersDictionary(store);
|
||||
var throwingActionType = new ThrowingAssignableType(typeof(IncrementAction), "simulated reducer snapshot failure");
|
||||
reducers.Add(throwingActionType, CreateEmptyReducerRegistrationList(reducers));
|
||||
|
||||
Assert.That(
|
||||
() => store.Dispatch(new IncrementAction(1)),
|
||||
Throws.InvalidOperationException.With.Message.EqualTo("simulated reducer snapshot failure"));
|
||||
|
||||
reducers.Remove(throwingActionType);
|
||||
|
||||
Assert.That(() => store.Dispatch(new IncrementAction(1)), Throws.Nothing);
|
||||
Assert.That(store.State.Count, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 测试未命中的 action 仍会记录诊断信息,但不会改变状态。
|
||||
/// </summary>
|
||||
@ -681,6 +710,32 @@ public class StoreTests
|
||||
return store;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 读取 Store 内部 reducer 字典,以便在异常安全回归测试中注入受控的异常源。
|
||||
/// </summary>
|
||||
/// <param name="store">要读取的 Store 实例。</param>
|
||||
/// <returns>Store 当前持有的 reducer 字典引用。</returns>
|
||||
private static IDictionary GetReducersDictionary(Store<CounterState> store)
|
||||
{
|
||||
var reducersField = typeof(Store<CounterState>).GetField("_reducers", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
?? throw new InvalidOperationException("Unable to locate Store reducer dictionary field.");
|
||||
|
||||
return (IDictionary)(reducersField.GetValue(store)
|
||||
?? throw new InvalidOperationException("Store reducer dictionary should not be null."));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建与 Store 私有 reducer 注册列表兼容的空列表实例。
|
||||
/// </summary>
|
||||
/// <param name="reducers">现有 reducer 字典,用于推断私有列表元素类型。</param>
|
||||
/// <returns>可写入私有 reducer 字典的空列表。</returns>
|
||||
private static object CreateEmptyReducerRegistrationList(IDictionary reducers)
|
||||
{
|
||||
var valueType = reducers.GetType().GenericTypeArguments[1];
|
||||
return Activator.CreateInstance(valueType)
|
||||
?? throw new InvalidOperationException("Unable to create an empty reducer registration list.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 用于测试的计数器状态。
|
||||
/// 使用 record 保持逻辑不可变语义,便于 Store 基于状态快照进行比较和断言。
|
||||
@ -876,4 +931,20 @@ public class StoreTests
|
||||
next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 用于在回归测试中稳定模拟 <see cref="Type.IsAssignableFrom(Type)"/> 失败的代理类型。
|
||||
/// </summary>
|
||||
private sealed class ThrowingAssignableType(Type delegatingType, string message) : TypeDelegator(delegatingType)
|
||||
{
|
||||
/// <summary>
|
||||
/// 在 Store 创建 reducer 快照时抛出受控异常,验证 dispatch 作用域能够正确回滚。
|
||||
/// </summary>
|
||||
/// <param name="typeInfo">待比较的 action 运行时类型。</param>
|
||||
/// <returns>此实现不会正常返回。</returns>
|
||||
public override bool IsAssignableFrom(Type? typeInfo)
|
||||
{
|
||||
throw new InvalidOperationException(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
using GFramework.Core.Abstractions.Command;
|
||||
using GFramework.Core.Rule;
|
||||
using GFramework.Cqrs.Abstractions.Cqrs.Command;
|
||||
|
||||
namespace GFramework.Core.Command;
|
||||
|
||||
@ -26,3 +27,54 @@ public abstract class AbstractAsyncCommand : ContextAwareBase, IAsyncCommand
|
||||
/// <returns>表示异步操作的任务</returns>
|
||||
protected abstract Task OnExecuteAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 抽象异步命令基类,为需要命令输入且无返回值的异步命令提供统一执行骨架。
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">命令输入类型,必须实现 <see cref="ICommandInput" /> 接口。</typeparam>
|
||||
/// <param name="input">命令输入参数。</param>
|
||||
public abstract class AbstractAsyncCommand<TInput>(TInput input) : ContextAwareBase, IAsyncCommand
|
||||
where TInput : ICommandInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 执行异步命令的实现方法。
|
||||
/// </summary>
|
||||
/// <returns>表示异步操作的任务。</returns>
|
||||
async Task IAsyncCommand.ExecuteAsync()
|
||||
{
|
||||
await OnExecuteAsync(input).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 定义异步执行逻辑的抽象方法,由派生类实现具体业务逻辑。
|
||||
/// </summary>
|
||||
/// <param name="input">命令输入参数。</param>
|
||||
/// <returns>表示异步操作的任务。</returns>
|
||||
protected abstract Task OnExecuteAsync(TInput input);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 抽象异步命令基类,为需要命令输入且返回结果的异步命令提供统一执行骨架。
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">命令输入类型,必须实现 <see cref="ICommandInput" /> 接口。</typeparam>
|
||||
/// <typeparam name="TResult">命令执行结果类型。</typeparam>
|
||||
/// <param name="input">命令输入参数。</param>
|
||||
public abstract class AbstractAsyncCommand<TInput, TResult>(TInput input) : ContextAwareBase, IAsyncCommand<TResult>
|
||||
where TInput : ICommandInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 执行异步命令并返回结果的实现方法。
|
||||
/// </summary>
|
||||
/// <returns>表示异步操作且包含结果的任务。</returns>
|
||||
async Task<TResult> IAsyncCommand<TResult>.ExecuteAsync()
|
||||
{
|
||||
return await OnExecuteAsync(input).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 定义异步执行逻辑的抽象方法,由派生类实现具体业务逻辑并返回结果。
|
||||
/// </summary>
|
||||
/// <param name="input">命令输入参数。</param>
|
||||
/// <returns>表示异步操作且包含结果的任务。</returns>
|
||||
protected abstract Task<TResult> OnExecuteAsync(TInput input);
|
||||
}
|
||||
|
||||
@ -1,29 +0,0 @@
|
||||
using GFramework.Core.Abstractions.Command;
|
||||
using GFramework.Core.Rule;
|
||||
using GFramework.Cqrs.Abstractions.Cqrs.Command;
|
||||
|
||||
namespace GFramework.Core.Command;
|
||||
|
||||
/// <summary>
|
||||
/// 抽象异步命令基类,用于处理无返回值的异步命令操作
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">命令输入类型,必须实现ICommandInput接口</typeparam>
|
||||
public abstract class AbstractAsyncCommand<TInput>(TInput input) : ContextAwareBase, IAsyncCommand
|
||||
where TInput : ICommandInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 执行异步命令的实现方法
|
||||
/// </summary>
|
||||
/// <returns>表示异步操作的任务</returns>
|
||||
async Task IAsyncCommand.ExecuteAsync()
|
||||
{
|
||||
await OnExecuteAsync(input).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 定义异步执行逻辑的抽象方法,由派生类实现具体业务逻辑
|
||||
/// </summary>
|
||||
/// <param name="input">命令输入参数</param>
|
||||
/// <returns>表示异步操作的任务</returns>
|
||||
protected abstract Task OnExecuteAsync(TInput input);
|
||||
}
|
||||
@ -1,30 +0,0 @@
|
||||
using GFramework.Core.Abstractions.Command;
|
||||
using GFramework.Core.Rule;
|
||||
using GFramework.Cqrs.Abstractions.Cqrs.Command;
|
||||
|
||||
namespace GFramework.Core.Command;
|
||||
|
||||
/// <summary>
|
||||
/// 抽象异步命令基类,用于处理有返回值的异步命令操作
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">命令输入类型,必须实现ICommandInput接口</typeparam>
|
||||
/// <typeparam name="TResult">命令执行结果类型</typeparam>
|
||||
public abstract class AbstractAsyncCommand<TInput, TResult>(TInput input) : ContextAwareBase, IAsyncCommand<TResult>
|
||||
where TInput : ICommandInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 执行异步命令并返回结果的实现方法
|
||||
/// </summary>
|
||||
/// <returns>表示异步操作且包含结果的任务</returns>
|
||||
async Task<TResult> IAsyncCommand<TResult>.ExecuteAsync()
|
||||
{
|
||||
return await OnExecuteAsync(input).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 定义异步执行逻辑的抽象方法,由派生类实现具体业务逻辑并返回结果
|
||||
/// </summary>
|
||||
/// <param name="input">命令输入参数</param>
|
||||
/// <returns>表示异步操作且包含结果的任务</returns>
|
||||
protected abstract Task<TResult> OnExecuteAsync(TInput input);
|
||||
}
|
||||
@ -1,5 +1,7 @@
|
||||
using GFramework.Core.Abstractions.Command;
|
||||
using GFramework.Core.Rule;
|
||||
using GFramework.Cqrs.Abstractions.Cqrs.Command;
|
||||
using ICommand = GFramework.Core.Abstractions.Command.ICommand;
|
||||
|
||||
namespace GFramework.Core.Command;
|
||||
|
||||
@ -20,4 +22,55 @@ public abstract class AbstractCommand : ContextAwareBase, ICommand
|
||||
/// 命令执行的抽象方法,由派生类实现具体的命令逻辑
|
||||
/// </summary>
|
||||
protected abstract void OnExecute();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 抽象命令类,实现 <see cref="ICommand" /> 接口,为需要命令输入的具体命令提供基础架构支持。
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">命令输入参数类型,必须实现 <see cref="ICommandInput" /> 接口。</typeparam>
|
||||
/// <param name="input">命令执行所需的输入参数。</param>
|
||||
public abstract class AbstractCommand<TInput>(TInput input) : ContextAwareBase, ICommand
|
||||
where TInput : ICommandInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 执行命令的入口方法,实现 <see cref="ICommand" /> 接口的 <c>Execute</c> 方法。
|
||||
/// </summary>
|
||||
void ICommand.Execute()
|
||||
{
|
||||
OnExecute(input);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 命令执行的抽象方法,由派生类实现具体的命令逻辑。
|
||||
/// </summary>
|
||||
/// <param name="input">命令执行所需的输入参数。</param>
|
||||
protected abstract void OnExecute(TInput input);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 带返回值的抽象命令类,为需要输入和返回值的命令提供统一执行骨架。
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">命令输入参数类型,必须实现 <see cref="ICommandInput" /> 接口。</typeparam>
|
||||
/// <typeparam name="TResult">命令执行后返回的结果类型。</typeparam>
|
||||
/// <param name="input">命令执行所需的输入参数。</param>
|
||||
public abstract class AbstractCommand<TInput, TResult>(TInput input)
|
||||
: ContextAwareBase, GFramework.Core.Abstractions.Command.ICommand<TResult>
|
||||
where TInput : ICommandInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 执行命令的入口方法,实现 <see cref="GFramework.Core.Abstractions.Command.ICommand{TResult}" /> 接口的
|
||||
/// <c>Execute</c> 方法。
|
||||
/// </summary>
|
||||
/// <returns>命令执行后的结果。</returns>
|
||||
TResult GFramework.Core.Abstractions.Command.ICommand<TResult>.Execute()
|
||||
{
|
||||
return OnExecute(input);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 命令执行的抽象方法,由派生类实现具体的命令逻辑。
|
||||
/// </summary>
|
||||
/// <param name="input">命令执行所需的输入参数。</param>
|
||||
/// <returns>命令执行后的结果。</returns>
|
||||
protected abstract TResult OnExecute(TInput input);
|
||||
}
|
||||
|
||||
@ -1,28 +0,0 @@
|
||||
using GFramework.Core.Rule;
|
||||
using GFramework.Cqrs.Abstractions.Cqrs.Command;
|
||||
using ICommand = GFramework.Core.Abstractions.Command.ICommand;
|
||||
|
||||
namespace GFramework.Core.Command;
|
||||
|
||||
/// <summary>
|
||||
/// 抽象命令类,实现 ICommand 接口,为具体命令提供基础架构支持
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">命令输入参数类型,必须实现 ICommandInput 接口</typeparam>
|
||||
/// <param name="input">命令执行所需的输入参数</param>
|
||||
public abstract class AbstractCommand<TInput>(TInput input) : ContextAwareBase, ICommand
|
||||
where TInput : ICommandInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 执行命令的入口方法,实现 ICommand 接口的 Execute 方法
|
||||
/// </summary>
|
||||
void ICommand.Execute()
|
||||
{
|
||||
OnExecute(input);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 命令执行的抽象方法,由派生类实现具体的命令逻辑
|
||||
/// </summary>
|
||||
/// <param name="input">命令执行所需的输入参数</param>
|
||||
protected abstract void OnExecute(TInput input);
|
||||
}
|
||||
@ -1,31 +0,0 @@
|
||||
using GFramework.Core.Rule;
|
||||
using GFramework.Cqrs.Abstractions.Cqrs.Command;
|
||||
|
||||
namespace GFramework.Core.Command;
|
||||
|
||||
/// <summary>
|
||||
/// 带返回值的抽象命令类,实现 ICommand{TResult} 接口,为需要返回结果的命令提供基础架构支持
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">命令输入参数类型,必须实现 ICommandInput 接口</typeparam>
|
||||
/// <typeparam name="TResult">命令执行后返回的结果类型</typeparam>
|
||||
/// <param name="input">命令执行所需的输入参数</param>
|
||||
public abstract class AbstractCommand<TInput, TResult>(TInput input)
|
||||
: ContextAwareBase, Abstractions.Command.ICommand<TResult>
|
||||
where TInput : ICommandInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 执行命令的入口方法,实现 ICommand{TResult} 接口的 Execute 方法
|
||||
/// </summary>
|
||||
/// <returns>命令执行后的结果</returns>
|
||||
TResult Abstractions.Command.ICommand<TResult>.Execute()
|
||||
{
|
||||
return OnExecute(input);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 命令执行的抽象方法,由派生类实现具体的命令逻辑
|
||||
/// </summary>
|
||||
/// <param name="input">命令执行所需的输入参数</param>
|
||||
/// <returns>命令执行后的结果</returns>
|
||||
protected abstract TResult OnExecute(TInput input);
|
||||
}
|
||||
@ -16,7 +16,7 @@ namespace GFramework.Core.Coroutine;
|
||||
/// </remarks>
|
||||
/// <param name="timeSource">缩放时间源,提供调度器默认推进所使用的时间数据。</param>
|
||||
/// <param name="instanceId">协程实例编号,用于生成带宿主前缀的句柄。</param>
|
||||
/// <param name="initialCapacity">调度器初始槽位容量。</param>
|
||||
/// <param name="initialCapacity">调度器初始槽位容量;允许为 0,此时首次启动协程会按需自动扩容。</param>
|
||||
/// <param name="enableStatistics">是否启用协程统计功能。</param>
|
||||
/// <param name="realtimeTimeSource">
|
||||
/// 非缩放时间源。
|
||||
@ -211,58 +211,10 @@ public sealed class CoroutineScheduler(
|
||||
return default;
|
||||
}
|
||||
|
||||
if (_nextSlot >= _slots.Length)
|
||||
{
|
||||
Expand();
|
||||
}
|
||||
|
||||
var handle = new CoroutineHandle(instanceId);
|
||||
var slotIndex = _nextSlot++;
|
||||
|
||||
var slot = new CoroutineSlot
|
||||
{
|
||||
CancellationToken = cancellationToken,
|
||||
Enumerator = coroutine,
|
||||
State = CoroutineState.Running,
|
||||
Handle = handle,
|
||||
Priority = priority
|
||||
};
|
||||
|
||||
if (cancellationToken.CanBeCanceled)
|
||||
{
|
||||
// 取消回调可能在任意线程触发,因此这里只做排队,真正清理由 Update 主线程完成。
|
||||
slot.CancellationRegistration = cancellationToken.Register(() => _pendingKills.Enqueue(handle));
|
||||
}
|
||||
|
||||
_slots[slotIndex] = slot;
|
||||
_metadata[handle] = new CoroutineMetadata
|
||||
{
|
||||
ExecutionStage = executionStage,
|
||||
Group = group,
|
||||
Priority = priority,
|
||||
SlotIndex = slotIndex,
|
||||
StartTime = _timeSource.CurrentTime * 1000,
|
||||
State = CoroutineState.Running,
|
||||
Tag = tag
|
||||
};
|
||||
|
||||
_completionSources[handle] =
|
||||
new TaskCompletionSource<CoroutineCompletionStatus>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_completionStatuses.Remove(handle);
|
||||
|
||||
if (!string.IsNullOrEmpty(tag))
|
||||
{
|
||||
AddTag(tag, handle);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(group))
|
||||
{
|
||||
AddGroup(group, handle);
|
||||
}
|
||||
|
||||
_statistics?.RecordStart(priority, tag);
|
||||
ActiveCoroutineCount++;
|
||||
|
||||
var slotIndex = AllocateSlotIndex();
|
||||
var slot = CreateRunningSlot(handle, coroutine, priority, cancellationToken);
|
||||
RegisterStartedCoroutine(handle, slotIndex, slot, priority, tag, group);
|
||||
Prewarm(slotIndex);
|
||||
UpdateStatisticsSnapshot();
|
||||
|
||||
@ -662,70 +614,14 @@ public sealed class CoroutineScheduler(
|
||||
CoroutineCompletionStatus completionStatus,
|
||||
Exception? exception = null)
|
||||
{
|
||||
var slot = _slots[slotIndex];
|
||||
if (slot == null)
|
||||
if (!TryGetFinalizableCoroutine(slotIndex, out var slot, out var handle))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var handle = slot.Handle;
|
||||
if (!handle.IsValid)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_metadata.TryGetValue(handle, out var meta))
|
||||
{
|
||||
if (meta.State == CoroutineState.Paused && _pausedCount > 0)
|
||||
{
|
||||
_pausedCount--;
|
||||
}
|
||||
|
||||
var executionTime = _timeSource.CurrentTime * 1000 - meta.StartTime;
|
||||
switch (completionStatus)
|
||||
{
|
||||
case CoroutineCompletionStatus.Completed:
|
||||
meta.State = CoroutineState.Completed;
|
||||
_statistics?.RecordComplete(executionTime, meta.Priority, meta.Tag);
|
||||
break;
|
||||
|
||||
case CoroutineCompletionStatus.Faulted:
|
||||
meta.State = CoroutineState.Completed;
|
||||
_statistics?.RecordFailure(meta.Priority, meta.Tag);
|
||||
break;
|
||||
|
||||
case CoroutineCompletionStatus.Cancelled:
|
||||
meta.State = CoroutineState.Cancelled;
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(completionStatus),
|
||||
completionStatus,
|
||||
"Unsupported coroutine completion status.");
|
||||
}
|
||||
}
|
||||
|
||||
DisposeSlotResources(slot);
|
||||
|
||||
_slots[slotIndex] = null;
|
||||
if (ActiveCoroutineCount > 0)
|
||||
{
|
||||
ActiveCoroutineCount--;
|
||||
}
|
||||
|
||||
RemoveTag(handle);
|
||||
RemoveGroup(handle);
|
||||
_metadata.Remove(handle);
|
||||
|
||||
WakeWaiters(handle);
|
||||
|
||||
if (_completionSources.Remove(handle, out var source))
|
||||
{
|
||||
source.TrySetResult(completionStatus);
|
||||
}
|
||||
|
||||
RecordCompletionStatus(handle, completionStatus);
|
||||
UpdateCompletionMetadata(handle, completionStatus);
|
||||
ReleaseCompletedCoroutine(slotIndex, slot, handle);
|
||||
CompleteCoroutineLifecycle(handle, completionStatus);
|
||||
OnCoroutineFinished?.Invoke(handle, completionStatus, exception);
|
||||
}
|
||||
|
||||
@ -799,6 +695,139 @@ public sealed class CoroutineScheduler(
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为新协程分配槽位索引,并在需要时扩容槽位数组。
|
||||
/// </summary>
|
||||
/// <returns>可写入的新槽位索引。</returns>
|
||||
private int AllocateSlotIndex()
|
||||
{
|
||||
if (_nextSlot >= _slots.Length)
|
||||
{
|
||||
Expand();
|
||||
}
|
||||
|
||||
return _nextSlot++;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建处于运行态的协程槽位,并在需要时挂接跨线程取消回调。
|
||||
/// </summary>
|
||||
/// <param name="handle">新协程句柄。</param>
|
||||
/// <param name="coroutine">协程枚举器。</param>
|
||||
/// <param name="priority">协程优先级。</param>
|
||||
/// <param name="cancellationToken">外部取消令牌。</param>
|
||||
/// <returns>已初始化的协程槽位。</returns>
|
||||
private CoroutineSlot CreateRunningSlot(
|
||||
CoroutineHandle handle,
|
||||
IEnumerator<IYieldInstruction> coroutine,
|
||||
CoroutinePriority priority,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var slot = new CoroutineSlot
|
||||
{
|
||||
CancellationToken = cancellationToken,
|
||||
Enumerator = coroutine,
|
||||
State = CoroutineState.Running,
|
||||
Handle = handle,
|
||||
Priority = priority
|
||||
};
|
||||
|
||||
RegisterCancellationCallback(slot, handle, cancellationToken);
|
||||
return slot;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为支持取消的协程注册待终止排队回调。
|
||||
/// </summary>
|
||||
/// <param name="slot">目标协程槽位。</param>
|
||||
/// <param name="handle">协程句柄。</param>
|
||||
/// <param name="cancellationToken">外部取消令牌。</param>
|
||||
private void RegisterCancellationCallback(
|
||||
CoroutineSlot slot,
|
||||
CoroutineHandle handle,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!cancellationToken.CanBeCanceled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 取消回调可能在任意线程触发,因此这里只做排队,真正清理由 Update 主线程完成。
|
||||
slot.CancellationRegistration = cancellationToken.Register(() => _pendingKills.Enqueue(handle));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将新协程写入调度器的槽位、元数据、标签分组和完成状态跟踪结构。
|
||||
/// </summary>
|
||||
/// <param name="handle">协程句柄。</param>
|
||||
/// <param name="slotIndex">槽位索引。</param>
|
||||
/// <param name="slot">已初始化的协程槽位。</param>
|
||||
/// <param name="priority">协程优先级。</param>
|
||||
/// <param name="tag">可选标签。</param>
|
||||
/// <param name="group">可选分组。</param>
|
||||
private void RegisterStartedCoroutine(
|
||||
CoroutineHandle handle,
|
||||
int slotIndex,
|
||||
CoroutineSlot slot,
|
||||
CoroutinePriority priority,
|
||||
string? tag,
|
||||
string? group)
|
||||
{
|
||||
_slots[slotIndex] = slot;
|
||||
_metadata[handle] = CreateCoroutineMetadata(slotIndex, priority, tag, group);
|
||||
ResetCompletionTracking(handle);
|
||||
|
||||
if (!string.IsNullOrEmpty(tag))
|
||||
{
|
||||
AddTag(tag, handle);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(group))
|
||||
{
|
||||
AddGroup(group, handle);
|
||||
}
|
||||
|
||||
_statistics?.RecordStart(priority, tag);
|
||||
ActiveCoroutineCount++;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建新协程的初始元数据。
|
||||
/// </summary>
|
||||
/// <param name="slotIndex">槽位索引。</param>
|
||||
/// <param name="priority">协程优先级。</param>
|
||||
/// <param name="tag">可选标签。</param>
|
||||
/// <param name="group">可选分组。</param>
|
||||
/// <returns>与新槽位对应的元数据对象。</returns>
|
||||
private CoroutineMetadata CreateCoroutineMetadata(
|
||||
int slotIndex,
|
||||
CoroutinePriority priority,
|
||||
string? tag,
|
||||
string? group)
|
||||
{
|
||||
return new CoroutineMetadata
|
||||
{
|
||||
ExecutionStage = executionStage,
|
||||
Group = group,
|
||||
Priority = priority,
|
||||
SlotIndex = slotIndex,
|
||||
StartTime = _timeSource.CurrentTime * 1000,
|
||||
State = CoroutineState.Running,
|
||||
Tag = tag
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 重置协程完成跟踪,使复用句柄不会携带上一轮完成结果。
|
||||
/// </summary>
|
||||
/// <param name="handle">协程句柄。</param>
|
||||
private void ResetCompletionTracking(CoroutineHandle handle)
|
||||
{
|
||||
_completionSources[handle] =
|
||||
new TaskCompletionSource<CoroutineCompletionStatus>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_completionStatuses.Remove(handle);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 释放单个槽位持有的资源。
|
||||
/// </summary>
|
||||
@ -824,6 +853,125 @@ public sealed class CoroutineScheduler(
|
||||
slot.Waiting = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 读取可被完成处理的协程槽位与句柄。
|
||||
/// 当槽位已空或句柄已失效时,说明该协程已经被其他路径清理,无需重复执行结束逻辑。
|
||||
/// </summary>
|
||||
/// <param name="slotIndex">槽位索引。</param>
|
||||
/// <param name="slot">若成功则返回槽位。</param>
|
||||
/// <param name="handle">若成功则返回句柄。</param>
|
||||
/// <returns>当存在可完成的协程时返回 <see langword="true" />。</returns>
|
||||
private bool TryGetFinalizableCoroutine(int slotIndex, out CoroutineSlot slot, out CoroutineHandle handle)
|
||||
{
|
||||
var candidate = _slots[slotIndex];
|
||||
if (candidate == null)
|
||||
{
|
||||
slot = null!;
|
||||
handle = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
handle = candidate.Handle;
|
||||
if (!handle.IsValid)
|
||||
{
|
||||
slot = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
slot = candidate;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据最终状态更新协程元数据与统计信息。
|
||||
/// </summary>
|
||||
/// <param name="handle">协程句柄。</param>
|
||||
/// <param name="completionStatus">最终结果。</param>
|
||||
private void UpdateCompletionMetadata(CoroutineHandle handle, CoroutineCompletionStatus completionStatus)
|
||||
{
|
||||
if (!_metadata.TryGetValue(handle, out var meta))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (meta.State == CoroutineState.Paused && _pausedCount > 0)
|
||||
{
|
||||
_pausedCount--;
|
||||
}
|
||||
|
||||
ApplyCompletionMetadata(meta, completionStatus);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将最终结果映射到元数据状态和统计记录。
|
||||
/// </summary>
|
||||
/// <param name="meta">协程元数据。</param>
|
||||
/// <param name="completionStatus">最终结果。</param>
|
||||
private void ApplyCompletionMetadata(CoroutineMetadata meta, CoroutineCompletionStatus completionStatus)
|
||||
{
|
||||
var executionTime = _timeSource.CurrentTime * 1000 - meta.StartTime;
|
||||
switch (completionStatus)
|
||||
{
|
||||
case CoroutineCompletionStatus.Completed:
|
||||
meta.State = CoroutineState.Completed;
|
||||
_statistics?.RecordComplete(executionTime, meta.Priority, meta.Tag);
|
||||
break;
|
||||
|
||||
case CoroutineCompletionStatus.Faulted:
|
||||
meta.State = CoroutineState.Completed;
|
||||
_statistics?.RecordFailure(meta.Priority, meta.Tag);
|
||||
break;
|
||||
|
||||
case CoroutineCompletionStatus.Cancelled:
|
||||
meta.State = CoroutineState.Cancelled;
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(completionStatus),
|
||||
completionStatus,
|
||||
"Unsupported coroutine completion status.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 释放已结束协程占用的槽位和索引结构。
|
||||
/// </summary>
|
||||
/// <param name="slotIndex">槽位索引。</param>
|
||||
/// <param name="slot">已结束的协程槽位。</param>
|
||||
/// <param name="handle">协程句柄。</param>
|
||||
private void ReleaseCompletedCoroutine(int slotIndex, CoroutineSlot slot, CoroutineHandle handle)
|
||||
{
|
||||
DisposeSlotResources(slot);
|
||||
|
||||
_slots[slotIndex] = null;
|
||||
if (ActiveCoroutineCount > 0)
|
||||
{
|
||||
ActiveCoroutineCount--;
|
||||
}
|
||||
|
||||
RemoveTag(handle);
|
||||
RemoveGroup(handle);
|
||||
_metadata.Remove(handle);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 完成协程的等待者唤醒、任务结果和完成历史记录。
|
||||
/// </summary>
|
||||
/// <param name="handle">协程句柄。</param>
|
||||
/// <param name="completionStatus">最终结果。</param>
|
||||
private void CompleteCoroutineLifecycle(CoroutineHandle handle, CoroutineCompletionStatus completionStatus)
|
||||
{
|
||||
WakeWaiters(handle);
|
||||
|
||||
if (_completionSources.Remove(handle, out var source))
|
||||
{
|
||||
source.TrySetResult(completionStatus);
|
||||
}
|
||||
|
||||
RecordCompletionStatus(handle, completionStatus);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 唤醒所有等待目标协程完成的协程。
|
||||
/// </summary>
|
||||
@ -888,7 +1036,9 @@ public sealed class CoroutineScheduler(
|
||||
/// </summary>
|
||||
private void Expand()
|
||||
{
|
||||
Array.Resize(ref _slots, _slots.Length * 2);
|
||||
// 允许构造器以 0 容量启动,用于极简场景或测试;首次分配时至少扩到 1,避免后续写槽位越界。
|
||||
var expandedLength = Math.Max(1, _slots.Length * 2);
|
||||
Array.Resize(ref _slots, expandedLength);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@ -3,24 +3,24 @@
|
||||
namespace GFramework.Core.Events;
|
||||
|
||||
/// <summary>
|
||||
/// 泛型事件类,支持一个泛型参数 T 的事件注册、注销与触发。
|
||||
/// 实现了 IEvent 接口以提供统一的事件操作接口。
|
||||
/// 泛型事件类,支持一个泛型参数 <typeparamref name="T" /> 的事件注册、注销与触发。
|
||||
/// 实现了 <see cref="IEvent" /> 接口以提供统一的事件操作接口。
|
||||
/// </summary>
|
||||
/// <typeparam name="T">事件回调函数的第一个参数类型。</typeparam>
|
||||
public class Event<T> : IEvent
|
||||
{
|
||||
/// <summary>
|
||||
/// 存储已注册的事件处理委托。
|
||||
/// 默认为空操作(no-op)委托,避免 null 检查。
|
||||
/// 未注册监听器时保持 <see langword="null" />,从而让监听器计数与真实订阅数量保持一致。
|
||||
/// </summary>
|
||||
private Action<T>? _mOnEvent = _ => { };
|
||||
private Action<T>? _mOnEvent;
|
||||
|
||||
/// <summary>
|
||||
/// 显式实现 IEvent 接口中的 Register 方法。
|
||||
/// 允许使用无参 Action 来订阅当前带参事件。
|
||||
/// 显式实现 <see cref="IEvent" /> 接口中的 <c>Register</c> 方法。
|
||||
/// 允许使用无参 <see cref="Action" /> 来订阅当前带参事件。
|
||||
/// </summary>
|
||||
/// <param name="onEvent">无参事件处理方法。</param>
|
||||
/// <returns>IUnRegister 对象,用于稍后注销该事件监听器。</returns>
|
||||
/// <returns><see cref="IUnRegister" /> 对象,用于稍后注销该事件监听器。</returns>
|
||||
IUnRegister IEvent.Register(Action onEvent)
|
||||
{
|
||||
return Register(Action);
|
||||
@ -35,7 +35,7 @@ public class Event<T> : IEvent
|
||||
/// 注册一个事件监听器,并返回可用于取消注册的对象。
|
||||
/// </summary>
|
||||
/// <param name="onEvent">要注册的事件处理方法。</param>
|
||||
/// <returns>IUnRegister 对象,用于稍后注销该事件监听器。</returns>
|
||||
/// <returns><see cref="IUnRegister" /> 对象,用于稍后注销该事件监听器。</returns>
|
||||
public IUnRegister Register(Action<T> onEvent)
|
||||
{
|
||||
_mOnEvent += onEvent;
|
||||
@ -52,7 +52,7 @@ public class Event<T> : IEvent
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 触发所有已注册的事件处理程序,并传递参数 t。
|
||||
/// 触发所有已注册的事件处理程序,并传递参数 <paramref name="t" />。
|
||||
/// </summary>
|
||||
/// <param name="t">传递给事件处理程序的参数。</param>
|
||||
public void Trigger(T t)
|
||||
@ -61,9 +61,9 @@ public class Event<T> : IEvent
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前已注册的监听器数量
|
||||
/// 获取当前已注册的监听器数量。
|
||||
/// </summary>
|
||||
/// <returns>监听器数量</returns>
|
||||
/// <returns>监听器数量。</returns>
|
||||
public int GetListenerCount()
|
||||
{
|
||||
return _mOnEvent?.GetInvocationList().Length ?? 0;
|
||||
@ -71,30 +71,30 @@ public class Event<T> : IEvent
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 支持两个泛型参数 T 和 TK 的事件类。
|
||||
/// 支持两个泛型参数 <typeparamref name="T" /> 和 <typeparamref name="TK" /> 的事件类。
|
||||
/// 提供事件注册、注销和触发功能。
|
||||
/// </summary>
|
||||
/// <typeparam name="T">第一个参数类型。</typeparam>
|
||||
/// <typeparam name="Tk">第二个参数类型。</typeparam>
|
||||
public class Event<T, Tk> : IEvent
|
||||
/// <typeparam name="TK">第二个参数类型。</typeparam>
|
||||
public class Event<T, TK> : IEvent
|
||||
{
|
||||
/// <summary>
|
||||
/// 存储已注册的双参数事件处理委托。
|
||||
/// 默认为空操作(no-op)委托。
|
||||
/// 未注册监听器时保持 <see langword="null" />,从而让监听器计数与真实订阅数量保持一致。
|
||||
/// </summary>
|
||||
private Action<T, Tk>? _mOnEvent = (_, _) => { };
|
||||
private Action<T, TK>? _mOnEvent;
|
||||
|
||||
/// <summary>
|
||||
/// 显式实现 IEvent 接口中的 Register 方法。
|
||||
/// 允许使用无参 Action 来订阅当前带参事件。
|
||||
/// 显式实现 <see cref="IEvent" /> 接口中的 <c>Register</c> 方法。
|
||||
/// 允许使用无参 <see cref="Action" /> 来订阅当前带参事件。
|
||||
/// </summary>
|
||||
/// <param name="onEvent">无参事件处理方法。</param>
|
||||
/// <returns>IUnRegister 对象,用于稍后注销该事件监听器。</returns>
|
||||
/// <returns><see cref="IUnRegister" /> 对象,用于稍后注销该事件监听器。</returns>
|
||||
IUnRegister IEvent.Register(Action onEvent)
|
||||
{
|
||||
return Register(Action);
|
||||
|
||||
void Action(T _, Tk __)
|
||||
void Action(T _, TK __)
|
||||
{
|
||||
onEvent();
|
||||
}
|
||||
@ -104,8 +104,8 @@ public class Event<T, Tk> : IEvent
|
||||
/// 注册一个接受两个参数的事件监听器,并返回可用于取消注册的对象。
|
||||
/// </summary>
|
||||
/// <param name="onEvent">要注册的事件处理方法。</param>
|
||||
/// <returns>IUnRegister 对象,用于稍后注销该事件监听器。</returns>
|
||||
public IUnRegister Register(Action<T, Tk> onEvent)
|
||||
/// <returns><see cref="IUnRegister" /> 对象,用于稍后注销该事件监听器。</returns>
|
||||
public IUnRegister Register(Action<T, TK> onEvent)
|
||||
{
|
||||
_mOnEvent += onEvent;
|
||||
return new DefaultUnRegister(() => UnRegister(onEvent));
|
||||
@ -115,27 +115,27 @@ public class Event<T, Tk> : IEvent
|
||||
/// 取消指定的双参数事件监听器。
|
||||
/// </summary>
|
||||
/// <param name="onEvent">需要被注销的事件处理方法。</param>
|
||||
public void UnRegister(Action<T, Tk> onEvent)
|
||||
public void UnRegister(Action<T, TK> onEvent)
|
||||
{
|
||||
_mOnEvent -= onEvent;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 触发所有已注册的事件处理程序,并传递参数 t 和 k。
|
||||
/// 触发所有已注册的事件处理程序,并传递参数 <paramref name="t" /> 和 <paramref name="k" />。
|
||||
/// </summary>
|
||||
/// <param name="t">第一个参数。</param>
|
||||
/// <param name="k">第二个参数。</param>
|
||||
public void Trigger(T t, Tk k)
|
||||
public void Trigger(T t, TK k)
|
||||
{
|
||||
_mOnEvent?.Invoke(t, k);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前已注册的监听器数量
|
||||
/// 获取当前已注册的监听器数量。
|
||||
/// </summary>
|
||||
/// <returns>监听器数量</returns>
|
||||
/// <returns>监听器数量。</returns>
|
||||
public int GetListenerCount()
|
||||
{
|
||||
return _mOnEvent?.GetInvocationList().Length ?? 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
using GFramework.Core.Abstractions.Query;
|
||||
using GFramework.Core.Rule;
|
||||
using GFramework.Cqrs.Abstractions.Cqrs.Query;
|
||||
|
||||
namespace GFramework.Core.Query;
|
||||
|
||||
@ -24,4 +25,31 @@ public abstract class AbstractAsyncQuery<TResult> : ContextAwareBase, IAsyncQuer
|
||||
/// </summary>
|
||||
/// <returns>返回查询结果的异步任务</returns>
|
||||
protected abstract Task<TResult> OnDoAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 抽象异步查询基类,为需要输入参数的异步查询提供统一执行骨架。
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">查询输入类型,必须实现 <see cref="IQueryInput" /> 接口。</typeparam>
|
||||
/// <typeparam name="TResult">查询结果类型。</typeparam>
|
||||
/// <param name="input">查询输入参数。</param>
|
||||
public abstract class AbstractAsyncQuery<TInput, TResult>(TInput input)
|
||||
: ContextAwareBase, IAsyncQuery<TResult>
|
||||
where TInput : IQueryInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 执行异步查询操作。
|
||||
/// </summary>
|
||||
/// <returns>返回查询结果的异步任务。</returns>
|
||||
public Task<TResult> DoAsync()
|
||||
{
|
||||
return OnDoAsync(input);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 抽象方法,用于实现具体的异步查询逻辑。
|
||||
/// </summary>
|
||||
/// <param name="input">查询输入参数。</param>
|
||||
/// <returns>返回查询结果的异步任务。</returns>
|
||||
protected abstract Task<TResult> OnDoAsync(TInput input);
|
||||
}
|
||||
|
||||
@ -1,33 +0,0 @@
|
||||
using GFramework.Core.Abstractions.Query;
|
||||
using GFramework.Core.Rule;
|
||||
using GFramework.Cqrs.Abstractions.Cqrs.Query;
|
||||
|
||||
namespace GFramework.Core.Query;
|
||||
|
||||
/// <summary>
|
||||
/// 抽象异步查询基类,用于处理输入类型为TInput、结果类型为TResult的异步查询操作
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">查询输入类型,必须实现IQueryInput接口</typeparam>
|
||||
/// <typeparam name="TResult">查询结果类型</typeparam>
|
||||
/// <param name="input">查询输入参数</param>
|
||||
public abstract class AbstractAsyncQuery<TInput, TResult>(
|
||||
TInput input
|
||||
) : ContextAwareBase, IAsyncQuery<TResult>
|
||||
where TInput : IQueryInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 执行异步查询操作
|
||||
/// </summary>
|
||||
/// <returns>返回查询结果的异步任务</returns>
|
||||
public Task<TResult> DoAsync()
|
||||
{
|
||||
return OnDoAsync(input);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 抽象方法,用于实现具体的异步查询逻辑
|
||||
/// </summary>
|
||||
/// <param name="input">查询输入参数</param>
|
||||
/// <returns>返回查询结果的异步任务</returns>
|
||||
protected abstract Task<TResult> OnDoAsync(TInput input);
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
using GFramework.Core.Abstractions.Query;
|
||||
using GFramework.Core.Rule;
|
||||
using GFramework.Cqrs.Abstractions.Cqrs.Query;
|
||||
|
||||
namespace GFramework.Core.Query;
|
||||
|
||||
@ -7,7 +8,7 @@ namespace GFramework.Core.Query;
|
||||
/// 抽象查询类,提供查询操作的基础实现
|
||||
/// </summary>
|
||||
/// <typeparam name="TResult">查询结果的类型</typeparam>
|
||||
public abstract class AbstractQuery<TResult> : ContextAwareBase, IQuery<TResult>
|
||||
public abstract class AbstractQuery<TResult> : ContextAwareBase, GFramework.Core.Abstractions.Query.IQuery<TResult>
|
||||
|
||||
{
|
||||
/// <summary>
|
||||
@ -25,4 +26,31 @@ public abstract class AbstractQuery<TResult> : ContextAwareBase, IQuery<TResult>
|
||||
/// </summary>
|
||||
/// <returns>查询结果,类型为TResult</returns>
|
||||
protected abstract TResult OnDo();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 抽象查询类,为需要输入参数的同步查询提供基础实现。
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">查询输入参数的类型,必须实现 <see cref="IQueryInput" /> 接口。</typeparam>
|
||||
/// <typeparam name="TResult">查询结果的类型。</typeparam>
|
||||
/// <param name="input">查询输入参数。</param>
|
||||
public abstract class AbstractQuery<TInput, TResult>(TInput input)
|
||||
: ContextAwareBase, GFramework.Core.Abstractions.Query.IQuery<TResult>
|
||||
where TInput : IQueryInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 执行查询操作。
|
||||
/// </summary>
|
||||
/// <returns>查询结果,类型为 <typeparamref name="TResult" />。</returns>
|
||||
public TResult Do()
|
||||
{
|
||||
return OnDo(input);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 抽象方法,用于实现具体的查询逻辑。
|
||||
/// </summary>
|
||||
/// <param name="input">查询输入参数。</param>
|
||||
/// <returns>查询结果。</returns>
|
||||
protected abstract TResult OnDo(TInput input);
|
||||
}
|
||||
|
||||
@ -1,30 +0,0 @@
|
||||
using GFramework.Core.Rule;
|
||||
using GFramework.Cqrs.Abstractions.Cqrs.Query;
|
||||
|
||||
namespace GFramework.Core.Query;
|
||||
|
||||
/// <summary>
|
||||
/// 抽象查询类,提供查询操作的基础实现
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">查询输入参数的类型,必须实现IQueryInput接口</typeparam>
|
||||
/// <typeparam name="TResult">查询结果的类型</typeparam>
|
||||
public abstract class AbstractQuery<TInput, TResult>(TInput input)
|
||||
: ContextAwareBase, Abstractions.Query.IQuery<TResult>
|
||||
where TInput : IQueryInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 执行查询操作
|
||||
/// </summary>
|
||||
/// <returns>查询结果,类型为TResult</returns>
|
||||
public TResult Do()
|
||||
{
|
||||
return OnDo(input);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 抽象方法,用于实现具体的查询逻辑
|
||||
/// </summary>
|
||||
/// <param name="input">查询输入参数</param>
|
||||
/// <returns>查询结果,类型为TResult</returns>
|
||||
protected abstract TResult OnDo(TInput input);
|
||||
}
|
||||
@ -212,10 +212,6 @@ public sealed class Store<TState> : IStore<TState>, IStoreDiagnostics<TState>
|
||||
ArgumentNullException.ThrowIfNull(action);
|
||||
|
||||
Action<TState>[] listenersSnapshot = Array.Empty<Action<TState>>();
|
||||
IStoreMiddleware<TState>[] middlewaresSnapshot = Array.Empty<IStoreMiddleware<TState>>();
|
||||
IStoreReducerAdapter[] reducersSnapshot = Array.Empty<IStoreReducerAdapter>();
|
||||
IEqualityComparer<TState> stateComparerSnapshot = _stateComparer;
|
||||
StoreDispatchContext<TState>? context = null;
|
||||
TState notificationState = default!;
|
||||
var hasNotification = false;
|
||||
var enteredDispatchScope = false;
|
||||
@ -224,49 +220,25 @@ public sealed class Store<TState> : IStore<TState>, IStoreDiagnostics<TState>
|
||||
{
|
||||
try
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
EnsureNotDispatching();
|
||||
_isDispatching = true;
|
||||
enteredDispatchScope = true;
|
||||
context = new StoreDispatchContext<TState>(action!, _state);
|
||||
stateComparerSnapshot = _stateComparer;
|
||||
middlewaresSnapshot = CreateMiddlewareSnapshotCore();
|
||||
reducersSnapshot = CreateReducerSnapshotCore(context.ActionType);
|
||||
}
|
||||
var context = EnterDispatchScope(
|
||||
action,
|
||||
out var middlewaresSnapshot,
|
||||
out var reducersSnapshot,
|
||||
out var stateComparerSnapshot);
|
||||
|
||||
enteredDispatchScope = true;
|
||||
|
||||
// middleware 和 reducer 可能包含较重的同步逻辑,因此仅持有 dispatch 串行门,
|
||||
// 不占用状态锁,让读取、订阅和注册操作只在需要访问共享状态时短暂阻塞。
|
||||
ExecuteDispatchPipeline(context, middlewaresSnapshot, reducersSnapshot, stateComparerSnapshot);
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_lastActionType = context.ActionType;
|
||||
_lastDispatchRecord = new StoreDispatchRecord<TState>(
|
||||
context.Action,
|
||||
context.PreviousState,
|
||||
context.NextState,
|
||||
context.HasStateChanged,
|
||||
context.DispatchedAt);
|
||||
|
||||
if (!context.HasStateChanged)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ApplyCommittedStateChange(context.NextState, context.DispatchedAt, context.Action);
|
||||
listenersSnapshot = CaptureListenersOrDeferNotification(context.NextState, out notificationState);
|
||||
hasNotification = listenersSnapshot.Length > 0;
|
||||
}
|
||||
hasNotification = TryCommitDispatchResult(context, out listenersSnapshot, out notificationState);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (enteredDispatchScope)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_isDispatching = false;
|
||||
}
|
||||
ExitDispatchScope();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -831,6 +803,99 @@ public sealed class Store<TState> : IStore<TState>, IStoreDiagnostics<TState>
|
||||
context.HasStateChanged = !stateComparer.Equals(context.PreviousState, nextState);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 进入一次新的 dispatch 作用域,并在状态锁内抓取本次执行所需的上下文快照。
|
||||
/// 该方法只做最短路径的共享状态访问,把 middleware/reducer 的实际执行留到锁外完成。
|
||||
/// </summary>
|
||||
/// <typeparam name="TAction">action 的具体类型。</typeparam>
|
||||
/// <param name="action">本次分发的 action。</param>
|
||||
/// <param name="middlewaresSnapshot">返回本次 dispatch 使用的中间件快照。</param>
|
||||
/// <param name="reducersSnapshot">返回本次 dispatch 使用的 reducer 快照。</param>
|
||||
/// <param name="stateComparerSnapshot">返回本次 dispatch 使用的状态比较器快照。</param>
|
||||
/// <returns>已初始化的 dispatch 上下文。</returns>
|
||||
private StoreDispatchContext<TState> EnterDispatchScope<TAction>(
|
||||
TAction action,
|
||||
out IStoreMiddleware<TState>[] middlewaresSnapshot,
|
||||
out IStoreReducerAdapter[] reducersSnapshot,
|
||||
out IEqualityComparer<TState> stateComparerSnapshot)
|
||||
{
|
||||
Debug.Assert(
|
||||
Monitor.IsEntered(_dispatchGate),
|
||||
"Caller must hold _dispatchGate before entering a dispatch scope.");
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
EnsureNotDispatching();
|
||||
_isDispatching = true;
|
||||
|
||||
try
|
||||
{
|
||||
var context = new StoreDispatchContext<TState>(action!, _state);
|
||||
stateComparerSnapshot = _stateComparer;
|
||||
middlewaresSnapshot = CreateMiddlewareSnapshotCore();
|
||||
reducersSnapshot = CreateReducerSnapshotCore(context.ActionType);
|
||||
return context;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 进入 dispatch 标记早于快照构建;如果这里抛异常,必须同步回滚标记,避免后续调用被永久判定为嵌套分发。
|
||||
_isDispatching = false;
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在 dispatch 管线执行完成后提交诊断信息和状态变更。
|
||||
/// 状态与订阅集合的更新统一在该阶段完成,从而保证 dispatch 与 time-travel 共享同一提交流程。
|
||||
/// </summary>
|
||||
/// <param name="context">刚完成 middleware/reducer 管线的 dispatch 上下文。</param>
|
||||
/// <param name="listenersSnapshot">若需要立即通知,则返回锁外回放的监听器快照。</param>
|
||||
/// <param name="notificationState">若需要立即通知,则返回要通知的状态。</param>
|
||||
/// <returns>本次 dispatch 是否需要在锁外执行监听器通知。</returns>
|
||||
private bool TryCommitDispatchResult(
|
||||
StoreDispatchContext<TState> context,
|
||||
out Action<TState>[] listenersSnapshot,
|
||||
out TState notificationState)
|
||||
{
|
||||
Debug.Assert(
|
||||
Monitor.IsEntered(_dispatchGate),
|
||||
"Caller must hold _dispatchGate before committing a dispatch result.");
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_lastActionType = context.ActionType;
|
||||
_lastDispatchRecord = new StoreDispatchRecord<TState>(
|
||||
context.Action,
|
||||
context.PreviousState,
|
||||
context.NextState,
|
||||
context.HasStateChanged,
|
||||
context.DispatchedAt);
|
||||
|
||||
if (!context.HasStateChanged)
|
||||
{
|
||||
listenersSnapshot = Array.Empty<Action<TState>>();
|
||||
notificationState = default!;
|
||||
return false;
|
||||
}
|
||||
|
||||
ApplyCommittedStateChange(context.NextState, context.DispatchedAt, context.Action);
|
||||
listenersSnapshot = CaptureListenersOrDeferNotification(context.NextState, out notificationState);
|
||||
return listenersSnapshot.Length > 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 退出当前 dispatch 作用域,允许后续 dispatch 或历史控制继续进入。
|
||||
/// </summary>
|
||||
private void ExitDispatchScope()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_isDispatching = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 确保当前 Store 没有发生重入分发或在 dispatch 中执行历史控制。
|
||||
/// </summary>
|
||||
@ -949,20 +1014,65 @@ public sealed class Store<TState> : IStore<TState>, IStoreDiagnostics<TState>
|
||||
|
||||
if (_actionMatchingMode == StoreActionMatchingMode.ExactTypeOnly)
|
||||
{
|
||||
if (!_reducers.TryGetValue(actionType, out var exactReducers) || exactReducers.Count == 0)
|
||||
{
|
||||
return Array.Empty<IStoreReducerAdapter>();
|
||||
}
|
||||
|
||||
var exactSnapshot = new IStoreReducerAdapter[exactReducers.Count];
|
||||
for (var i = 0; i < exactReducers.Count; i++)
|
||||
{
|
||||
exactSnapshot[i] = exactReducers[i].Adapter;
|
||||
}
|
||||
|
||||
return exactSnapshot;
|
||||
return CreateExactReducerSnapshot(actionType);
|
||||
}
|
||||
|
||||
return CreateAssignableReducerSnapshot(actionType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为精确类型匹配模式创建 reducer 快照。
|
||||
/// </summary>
|
||||
/// <param name="actionType">当前 action 的运行时类型。</param>
|
||||
/// <returns>精确匹配到的 reducer 快照;若未注册则返回空数组。</returns>
|
||||
private IStoreReducerAdapter[] CreateExactReducerSnapshot(Type actionType)
|
||||
{
|
||||
if (!_reducers.TryGetValue(actionType, out var exactReducers) || exactReducers.Count == 0)
|
||||
{
|
||||
return Array.Empty<IStoreReducerAdapter>();
|
||||
}
|
||||
|
||||
var exactSnapshot = new IStoreReducerAdapter[exactReducers.Count];
|
||||
for (var i = 0; i < exactReducers.Count; i++)
|
||||
{
|
||||
exactSnapshot[i] = exactReducers[i].Adapter;
|
||||
}
|
||||
|
||||
return exactSnapshot;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为多态匹配模式创建 reducer 快照。
|
||||
/// 该路径会收集所有可赋值的注册桶,并按“精确类型 -> 基类距离 -> 接口 -> 注册顺序”的稳定规则排序。
|
||||
/// </summary>
|
||||
/// <param name="actionType">当前 action 的运行时类型。</param>
|
||||
/// <returns>多态模式下的 reducer 快照;若未注册则返回空数组。</returns>
|
||||
private IStoreReducerAdapter[] CreateAssignableReducerSnapshot(Type actionType)
|
||||
{
|
||||
var matches = CollectReducerMatches(actionType);
|
||||
if (matches is null || matches.Count == 0)
|
||||
{
|
||||
return Array.Empty<IStoreReducerAdapter>();
|
||||
}
|
||||
|
||||
matches.Sort(CompareReducerMatch);
|
||||
|
||||
var snapshot = new IStoreReducerAdapter[matches.Count];
|
||||
for (var i = 0; i < matches.Count; i++)
|
||||
{
|
||||
snapshot[i] = matches[i].Adapter;
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 收集当前 action 类型可命中的 reducer 注册,并附带稳定排序所需的匹配元数据。
|
||||
/// </summary>
|
||||
/// <param name="actionType">当前 action 的运行时类型。</param>
|
||||
/// <returns>匹配结果列表;若没有任何匹配则返回 <see langword="null"/>。</returns>
|
||||
private List<ReducerMatch>? CollectReducerMatches(Type actionType)
|
||||
{
|
||||
List<ReducerMatch>? matches = null;
|
||||
|
||||
foreach (var reducerBucket in _reducers)
|
||||
@ -984,35 +1094,30 @@ public sealed class Store<TState> : IStore<TState>, IStoreDiagnostics<TState>
|
||||
}
|
||||
}
|
||||
|
||||
if (matches is null || matches.Count == 0)
|
||||
return matches;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 比较两个 reducer 匹配结果的优先级,保证多态匹配下的执行顺序稳定可预测。
|
||||
/// </summary>
|
||||
/// <param name="left">左侧匹配结果。</param>
|
||||
/// <param name="right">右侧匹配结果。</param>
|
||||
/// <returns>排序比较结果。</returns>
|
||||
private static int CompareReducerMatch(ReducerMatch left, ReducerMatch right)
|
||||
{
|
||||
var categoryComparison = left.MatchCategory.CompareTo(right.MatchCategory);
|
||||
if (categoryComparison != 0)
|
||||
{
|
||||
return Array.Empty<IStoreReducerAdapter>();
|
||||
return categoryComparison;
|
||||
}
|
||||
|
||||
matches.Sort(static (left, right) =>
|
||||
var distanceComparison = left.InheritanceDistance.CompareTo(right.InheritanceDistance);
|
||||
if (distanceComparison != 0)
|
||||
{
|
||||
var categoryComparison = left.MatchCategory.CompareTo(right.MatchCategory);
|
||||
if (categoryComparison != 0)
|
||||
{
|
||||
return categoryComparison;
|
||||
}
|
||||
|
||||
var distanceComparison = left.InheritanceDistance.CompareTo(right.InheritanceDistance);
|
||||
if (distanceComparison != 0)
|
||||
{
|
||||
return distanceComparison;
|
||||
}
|
||||
|
||||
return left.Sequence.CompareTo(right.Sequence);
|
||||
});
|
||||
|
||||
var snapshot = new IStoreReducerAdapter[matches.Count];
|
||||
for (var i = 0; i < matches.Count; i++)
|
||||
{
|
||||
snapshot[i] = matches[i].Adapter;
|
||||
return distanceComparison;
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
return left.Sequence.CompareTo(right.Sequence);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -1412,4 +1517,4 @@ public sealed class Store<TState> : IStore<TState>, IStoreDiagnostics<TState>
|
||||
/// </summary>
|
||||
public TState PendingState { get; set; } = default!;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,22 +7,26 @@
|
||||
|
||||
## 当前恢复点
|
||||
|
||||
- 恢复点编号:`ANALYZER-WARNING-REDUCTION-RP-005`
|
||||
- 当前阶段:`Phase 5`
|
||||
- 恢复点编号:`ANALYZER-WARNING-REDUCTION-RP-012`
|
||||
- 当前阶段:`Phase 12`
|
||||
- 当前焦点:
|
||||
- 已完成 `GFramework.Core/Pause/PauseStackManager.cs` 的 `MA0051` 收口:将 `DestroyAsync` 与 `Pop` 拆分为锁内状态迁移、
|
||||
栈调整和锁外通知三个阶段,同时保持日志、事件与销毁补发语义不变
|
||||
- 已为销毁路径补充 `PauseStackManagerTests.DestroyAsync_Should_NotifyResumedGroups` 回归测试,覆盖“销毁时向所有仍暂停组补发恢复通知”
|
||||
- 下一轮若继续推进,优先在 `CoroutineScheduler` 或 `Store` 的剩余 `MA0051` 中只选一个切入点,不回到已完成的
|
||||
`PauseStackManager`
|
||||
- 当前 PR review workflow 已补强到支持 JSON 落盘与按 section/path 收窄输出;下一轮恢复到 `MA0046` 主批次
|
||||
- 后续继续按 warning 类型和数量批处理,而不是回退到按单文件切片推进
|
||||
- 当某一轮主类型数量不足时,允许顺手合并其他低冲突 warning 类型,`MA0015` 与 `MA0077`
|
||||
只是当前最明显的低数量示例,不构成限定
|
||||
- 单次 `boot` 的工作树改动上限控制在约 `100` 个文件以内,避免 recovery context 与 review 面同时失控
|
||||
- 若任务边界互不冲突,允许使用不同模型的 subagent 并行处理不同 warning 类型或不同目录,但必须遵守显式 ownership
|
||||
|
||||
## 当前状态摘要
|
||||
|
||||
- 已完成 `GFramework.Core`、`GFramework.Cqrs`、`GFramework.Godot` 与部分 source generator 的低风险 warning 清理
|
||||
- 已完成多轮 CodeRabbit follow-up 修复,并用定向测试与项目/解决方案构建验证了关键回归风险
|
||||
- 当前 `PauseStackManager` 的长方法 warning 已从 active 入口移除;主题内剩余 warning 主要集中在
|
||||
`GFramework.Core/Coroutine/CoroutineScheduler.cs`、`GFramework.Core/StateManagement/Store.cs`、文件/类型命名冲突、
|
||||
delegate 形状和少量公共集合抽象接口问题
|
||||
- 已完成当前 PR #265 review follow-up:修复 `CoroutineScheduler` 的零容量扩容边界,并补上 `Store` dispatch 作用域的异常安全回滚
|
||||
- 已继续完成当前 PR #265 review follow-up:修复 `Event<T>` 与 `Event<T, TK>` 监听器计数的 off-by-one,并补充回归测试
|
||||
- 已增强 `gframework-pr-review` 脚本与 skill 文档,降低超长 JSON 直出导致的 review 信号漏看风险
|
||||
- 当前 `PauseStackManager`、`Store`、`CoroutineScheduler` 与 `GFramework.Core` 的 `MA0048`
|
||||
文件/类型命名冲突已从 active 入口移除;主题内剩余 warning 主要集中在 `MA0046` delegate 形状、
|
||||
`MA0016` 集合抽象接口、`MA0002` comparer 重载,以及 `MA0015` / `MA0077` 两个低数量尾项
|
||||
|
||||
## 当前活跃事实
|
||||
|
||||
@ -32,16 +36,33 @@
|
||||
- `RP-003` 已在不改生命周期契约的前提下完成 `ArchitectureLifecycle` 初始化主流程拆分,并通过定向 build/test 验证
|
||||
- `RP-004` 已完成当前 PR review follow-up:修复 `TryCreateGeneratedRegistry` 的可空 `out` 契约并清理 trace 文档重复标题
|
||||
- `RP-005` 已在不改公共 API 的前提下完成 `PauseStackManager` 两个 `MA0051` 的结构拆分,并补充销毁通知回归测试
|
||||
- `RP-006` 已在不改公共 API 的前提下完成 `Store` 两个 `MA0051` 的结构拆分,并通过定向 build/test 验证 dispatch、
|
||||
多态 reducer 匹配与历史语义未回归
|
||||
- `RP-007` 已在不改公共 API 的前提下完成 `CoroutineScheduler` 两个 `MA0051` 的结构拆分,并通过定向 build/test 验证
|
||||
调度、取消与完成状态语义未回归
|
||||
- `RP-008` 将后续策略从“单文件 warning 切片”切换为“按类型批处理 + 文件数上限控制”,并允许在非冲突前提下使用
|
||||
不同模型的 subagent 并行处理
|
||||
- `RP-009` 在不改公共 API 的前提下,将同名泛型家族收拢到与类型名一致的单文件中,清空当前 `GFramework.Core`
|
||||
`net8.0` 基线中的 `MA0048`,并通过定向 build/test 验证 `Command`、`Query`、`Event` 路径未回归
|
||||
- `RP-010` 使用 `gframework-pr-review` 复核当前分支 PR #265 后,修复了仍在本地成立的两个 follow-up 风险:
|
||||
`CoroutineScheduler` 的 `initialCapacity: 0` 扩容越界,以及 `Store` 在 dispatch 快照阶段抛异常时可能残留
|
||||
`_isDispatching = true` 的锁死问题
|
||||
- `RP-011` 根据补充复核继续收口 PR #265 的 outside-diff comment,修复 `Event<T>` / `Event<T, TK>` 默认 no-op
|
||||
委托导致的 `GetListenerCount()` off-by-one,并以定向事件测试验证注册、注销和计数语义
|
||||
- `RP-012` 为 `gframework-pr-review` 增加 `--json-output`、`--section`、`--path` 与文本截断能力,并更新 skill 推荐用法,
|
||||
让“先落盘、再定向抽取”成为默认可操作路径
|
||||
- 当前工作树分支 `fix/analyzer-warning-reduction-batch` 已在 `ai-plan/public/README.md` 建立 topic 映射
|
||||
|
||||
## 当前风险
|
||||
|
||||
- 结构性重构风险:剩余 `GFramework.Core` 侧 `MA0051` 与 `MA0048` 可能要求较大的文件拆分或类型重命名
|
||||
- 缓解措施:只在下一轮明确接受结构调整成本时再继续推进,不在恢复点模糊的情况下顺手扩面
|
||||
- 公共契约兼容风险:剩余 `MA0046` / `MA0016` 若直接改公开委托或集合类型,可能波及用户代码
|
||||
- 缓解措施:优先选择不改公共 API 的低风险切法;若必须触达公共契约,先补齐 XML 契约说明与定向测试
|
||||
- 测试宿主稳定性风险:部分 Godot 失败路径在当前 .NET 测试宿主下仍不稳定
|
||||
- 缓解措施:继续优先使用稳定的 targeted test、项目构建和相邻 smoke test 组合验证
|
||||
- 多目标框架 warning 解释风险:同一源位置会在多个 target framework 下重复计数
|
||||
- 缓解措施:继续以唯一源位置和 warning 家族为主要决策依据,而不是只看原始 warning 总数
|
||||
- 并行实现风险:批量收敛时若 subagent 写入边界不清晰,容易引入命名冲突或重复重构
|
||||
- 缓解措施:只在 warning 类型或目录边界清晰时并行;每个 subagent 必须有独占文件 ownership,主代理负责合并验证
|
||||
|
||||
## 活跃文档
|
||||
|
||||
@ -65,11 +86,46 @@
|
||||
- 结果:`27 Warning(s)`,`0 Error(s)`;`PauseStackManager.cs` 已不再出现在 `MA0051` 列表中
|
||||
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --filter FullyQualifiedName~PauseStackManagerTests -p:RestoreFallbackFolders=`
|
||||
- 结果:`25 Passed`,`0 Failed`
|
||||
- `RP-006` 的定向验证结果:
|
||||
- `dotnet build GFramework.Core/GFramework.Core.csproj -c Release -t:Rebuild --no-restore -p:UseSharedCompilation=false -p:TargetFramework=net8.0 -p:RestoreFallbackFolders="" -p:RestorePackagesPath=<linux-nuget-cache> -nologo -clp:"Summary;WarningsOnly"`
|
||||
- 结果:`25 Warning(s)`,`0 Error(s)`;`Store.cs` 已不再出现在 `MA0051` 列表中
|
||||
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --filter FullyQualifiedName~StoreTests -p:RestoreFallbackFolders="" -p:RestorePackagesPath=<linux-nuget-cache> -nologo`
|
||||
- 结果:`30 Passed`,`0 Failed`
|
||||
- `RP-007` 的定向验证结果:
|
||||
- `dotnet build GFramework.Core/GFramework.Core.csproj -c Release -t:Rebuild --no-restore -p:UseSharedCompilation=false -p:TargetFramework=net8.0 -p:RestoreFallbackFolders="" -p:RestorePackagesPath=<linux-nuget-cache> -nologo -clp:"Summary;WarningsOnly"`
|
||||
- 结果:`23 Warning(s)`,`0 Error(s)`;`CoroutineScheduler.cs` 已不再出现在 `MA0051` 列表中
|
||||
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --filter FullyQualifiedName~CoroutineScheduler -p:RestoreFallbackFolders="" -p:RestorePackagesPath=<linux-nuget-cache> -nologo`
|
||||
- 结果:`34 Passed`,`0 Failed`
|
||||
- `RP-008` 的策略基线:
|
||||
- 当前 `GFramework.Core` 剩余 warning 分布:`MA0048=8`、`MA0046=6`、`MA0016=5`、`MA0002=2`、`MA0015=1`、`MA0077=1`
|
||||
- 后续批处理规则:优先按类型推进;若当轮主类型数量不足,可顺手吸收其他低冲突类型,不限定于 `MA0015` 与 `MA0077`
|
||||
- `RP-009` 的定向验证结果:
|
||||
- `dotnet build GFramework.Core/GFramework.Core.csproj -c Release -t:Rebuild --no-restore -p:UseSharedCompilation=false -p:TargetFramework=net8.0 -p:RestoreFallbackFolders="" -nologo -clp:"Summary;WarningsOnly"`
|
||||
- 结果:`15 Warning(s)`,`0 Error(s)`;当前 `GFramework.Core` `net8.0` warnings-only 输出中已不再出现 `MA0048`
|
||||
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --filter "FullyQualifiedName~CommandExecutorTests|FullyQualifiedName~AbstractAsyncCommandTests|FullyQualifiedName~QueryExecutorTests|FullyQualifiedName~AbstractAsyncQueryTests|FullyQualifiedName~EventTests" -m:1 -p:RestoreFallbackFolders="" -nologo`
|
||||
- 结果:`83 Passed`,`0 Failed`
|
||||
- `RP-010` 的定向验证结果:
|
||||
- `dotnet build GFramework.Core/GFramework.Core.csproj -c Release --no-restore -p:TargetFramework=net8.0 -p:RestoreFallbackFolders="" -nologo`
|
||||
- 结果:`15 Warning(s)`,`0 Error(s)`;新增修复未引入新的 `GFramework.Core` `net8.0` 构建错误
|
||||
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --filter "FullyQualifiedName~CoroutineSchedulerTests.Run_Should_Grow_From_Zero_Initial_Capacity|FullyQualifiedName~StoreTests.Dispatch_Should_Reset_Dispatching_Flag_When_Snapshot_Creation_Throws" -m:1 -p:RestoreFallbackFolders="" -nologo`
|
||||
- 结果:`2 Passed`,`0 Failed`
|
||||
- `RP-011` 的定向验证结果:
|
||||
- `dotnet build GFramework.Core/GFramework.Core.csproj -c Release --no-restore -p:TargetFramework=net8.0 -p:RestoreFallbackFolders="" -nologo`
|
||||
- 结果:`15 Warning(s)`,`0 Error(s)`;`Event.cs` 的 listener count 修复未引入新的 `GFramework.Core` `net8.0` 构建错误
|
||||
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --filter "FullyQualifiedName~EventTests.EventT_GetListenerCount_Should_Exclude_Placeholder_Handler|FullyQualifiedName~EventTests.EventTTK_GetListenerCount_Should_Exclude_Placeholder_Handler" -m:1 -p:RestoreFallbackFolders="" -nologo`
|
||||
- 结果:`2 Passed`,`0 Failed`
|
||||
- `RP-012` 的定向验证结果:
|
||||
- `python3 -m py_compile .codex/skills/gframework-pr-review/scripts/fetch_current_pr_review.py`
|
||||
- 结果:通过;使用 `PYTHONPYCACHEPREFIX=/tmp/codex-pycache` 规避技能目录只读导致的 `__pycache__` 写入限制
|
||||
- `python3 .codex/skills/gframework-pr-review/scripts/fetch_current_pr_review.py --help`
|
||||
- 结果:通过;`--json-output`、`--section`、`--path`、`--max-description-length` 已出现在 CLI 帮助中
|
||||
- `dotnet build GFramework.Core/GFramework.Core.csproj -c Release --no-restore -p:TargetFramework=net8.0 -p:RestoreFallbackFolders="" -nologo`
|
||||
- 结果:`0 Warning(s)`,`0 Error(s)`
|
||||
- active 跟踪文件只保留当前恢复点、活跃事实、风险与下一步,不再重复保存已完成阶段的长篇历史
|
||||
|
||||
## 下一步
|
||||
|
||||
1. 若要继续该主题,先读 active tracking,再按需展开历史归档中的 warning 热点与验证记录
|
||||
2. 优先在 `GFramework.Core/Coroutine/CoroutineScheduler.cs` 与 `GFramework.Core/StateManagement/Store.cs`
|
||||
的 `MA0051` 中只选一个继续,不要在同一轮同时扩多个风险面
|
||||
3. 若本主题确认暂缓,可保持当前归档状态,不需要再恢复 `local-plan/`
|
||||
2. 下一轮优先以 `MA0046` 为主批次启动,先从 `Architecture*` 与 `CoroutineScheduler` 的低风险 delegate 形状修正中选一个切入点
|
||||
3. 若 `MA0046` 的文件 ownership 可以清晰切分,允许使用不同模型的 subagent 并行处理互不冲突的目录或类型簇
|
||||
4. 若本主题确认暂缓,可保持当前归档状态,不需要再恢复 `local-plan/`
|
||||
|
||||
@ -1,5 +1,198 @@
|
||||
# Analyzer Warning Reduction 追踪
|
||||
|
||||
## 2026-04-21 — RP-012
|
||||
|
||||
### 阶段:PR review workflow 输出收窄增强(RP-012)
|
||||
|
||||
- 背景:上一轮虽然脚本已经能解析 `outside_diff_comments`,但直接把超长 JSON 打到终端时仍可能因为输出截断而漏看高价值 review 信号
|
||||
- 本轮对 `gframework-pr-review` 做了工作流级增强,而不是继续依赖 shell 重定向技巧:
|
||||
- 为 `fetch_current_pr_review.py` 增加 `--json-output <path>`,允许把完整 JSON 稳定写入文件
|
||||
- 增加 `--section`,可只输出 `outside-diff`、`open-threads`、`megalinter` 等高信号文本摘要
|
||||
- 增加 `--path`,允许把文本输出收窄到特定文件或路径片段
|
||||
- 增加 `--max-description-length`,避免超长 comment/body 在 text 模式下刷屏
|
||||
- 当 text 模式搭配 `--json-output` 时,stdout 保持精简,并显式提示完整 JSON 文件路径
|
||||
- 同步更新 `SKILL.md`:
|
||||
- 将“先落盘,再用 `jq` 或 `--section` / `--path` 缩小范围”写成推荐机器工作流
|
||||
- 补充按 section 和按路径聚焦的示例命令
|
||||
- 预期收益:
|
||||
- 不再要求操作者肉眼阅读整份长 JSON
|
||||
- outside-diff、nitpick 和 open thread 都能成为一等可过滤输出
|
||||
- 即使终端输出有 token/长度上限,完整结果仍可通过文件稳定回查
|
||||
- 定向验证命令:
|
||||
- `python3 -m py_compile .codex/skills/gframework-pr-review/scripts/fetch_current_pr_review.py`
|
||||
- 结果:通过;使用 `PYTHONPYCACHEPREFIX=/tmp/codex-pycache` 规避 `__pycache__` 写入限制
|
||||
- `python3 .codex/skills/gframework-pr-review/scripts/fetch_current_pr_review.py --help`
|
||||
- 结果:通过;新增 CLI 选项均已出现在帮助输出中
|
||||
- `dotnet build GFramework.Core/GFramework.Core.csproj -c Release --no-restore -p:TargetFramework=net8.0 -p:RestoreFallbackFolders="" -nologo`
|
||||
- 结果:`0 Warning(s)`,`0 Error(s)`
|
||||
- 下一步建议:
|
||||
- 之后执行 `$gframework-pr-review` 时,默认优先使用 `--json-output`
|
||||
- 在 review 跟进阶段,先看 `outside-diff`、`open-threads`、`megalinter` 三个 section,再决定是否需要打开完整 JSON
|
||||
|
||||
## 2026-04-21 — RP-011
|
||||
|
||||
### 阶段:PR #265 outside-diff follow-up 补收口(RP-011)
|
||||
|
||||
- 用户补充指出 CodeRabbit 在 `Some comments are outside the diff` 中还有 `GFramework.Core/Events/Event.cs` 的 minor finding:
|
||||
默认 no-op 委托会被 `GetInvocationList()` 计入,导致 `GetListenerCount()` 在无监听器和单监听器场景分别返回 `1` 和 `2`
|
||||
- 本地复核确认该问题仍成立:
|
||||
- `Event<T>` 当前字段初始化为 `_ => { }`
|
||||
- `Event<T, TK>` 当前字段初始化为 `(_, _) => { }`
|
||||
- 两个 `Trigger(...)` 实现本身已是 null-safe,因此无需依赖占位委托规避空引用
|
||||
- 实施最小修复:
|
||||
- 移除两个事件字段的 no-op 初始委托,改为以 `null` 表示“无监听器”
|
||||
- 保持 `Register` / `UnRegister` / `Trigger` 的公开 API 和调用方式不变
|
||||
- 在 `EventTests` 中新增单参数与双参数 `GetListenerCount()` 回归测试,覆盖初始值、注册后和注销后的计数语义
|
||||
- 过程说明:
|
||||
- 这条不是 skill 设计遗漏;`gframework-pr-review` 的目标本来就包含 latest review body 和 outside-diff 信号
|
||||
- 上一轮是我在处理时漏看了这条 outside-diff item,且终端里展示的超长 JSON 输出被截断,未单独把 `Event.cs` 项再抽出来复核
|
||||
- 定向验证命令:
|
||||
- `dotnet build GFramework.Core/GFramework.Core.csproj -c Release --no-restore -p:TargetFramework=net8.0 -p:RestoreFallbackFolders="" -nologo`
|
||||
- 结果:`15 Warning(s)`,`0 Error(s)`
|
||||
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --filter "FullyQualifiedName~EventTests.EventT_GetListenerCount_Should_Exclude_Placeholder_Handler|FullyQualifiedName~EventTests.EventTTK_GetListenerCount_Should_Exclude_Placeholder_Handler" -m:1 -p:RestoreFallbackFolders="" -nologo`
|
||||
- 结果:`2 Passed`,`0 Failed`
|
||||
- 下一步建议:
|
||||
- 若继续 PR #265 follow-up,只接受当前本地仍成立的剩余 outside-diff 或 unresolved review 项
|
||||
- 若没有新的有效 review 点,再恢复到 `MA0046` 主批次
|
||||
|
||||
## 2026-04-21 — RP-010
|
||||
|
||||
### 阶段:PR #265 follow-up 收口(RP-010)
|
||||
|
||||
- 使用 `gframework-pr-review` 抓取当前分支 PR #265 的 latest head review threads、CodeRabbit review body、MegaLinter 摘要与 CTRF
|
||||
测试结果;确认最新 unresolved thread 只剩 `CoroutineScheduler` 零容量扩容边界
|
||||
- 本地复核后确认两处仍成立的风险:
|
||||
- `CoroutineScheduler.Expand()` 在 `_slots.Length == 0` 时会把容量从 `0` 扩到 `0`,首次 `Run` 写槽位会越界
|
||||
- `Store.EnterDispatchScope()` 在 `_isDispatching = true` 之后、快照构建完成之前若抛异常,会留下永久的嵌套分发误判
|
||||
- 实施最小修复:
|
||||
- 将 `Expand()` 调整为 `Math.Max(1, _slots.Length * 2)`,保持已有倍增策略,只补上零容量边界
|
||||
- 为 `EnterDispatchScope()` 增加快照阶段的异常回滚,确保 `_isDispatching` 与实际 dispatch 生命周期保持一致
|
||||
- 新增回归测试覆盖零容量启动路径,以及 dispatch 快照阶段抛错后的可恢复性
|
||||
- 当前 PR 信号复核结论:
|
||||
- CTRF:最新评论显示 `2135 passed / 0 failed`
|
||||
- MegaLinter:唯一告警仍是 CI 中 `dotnet-format` restore 失败,未发现新的本地代码格式问题
|
||||
- 旧 review body 中提到的 `Store` 异常安全问题虽未表现为最新 open thread,但在本地代码中仍可成立,因此一并收口
|
||||
- 定向验证命令:
|
||||
- `dotnet build GFramework.Core/GFramework.Core.csproj -c Release --no-restore -p:TargetFramework=net8.0 -p:RestoreFallbackFolders="" -nologo`
|
||||
- 结果:`15 Warning(s)`,`0 Error(s)`
|
||||
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --filter "FullyQualifiedName~CoroutineSchedulerTests.Run_Should_Grow_From_Zero_Initial_Capacity|FullyQualifiedName~StoreTests.Dispatch_Should_Reset_Dispatching_Flag_When_Snapshot_Creation_Throws" -m:1 -p:RestoreFallbackFolders="" -nologo`
|
||||
- 结果:`2 Passed`,`0 Failed`
|
||||
- 下一步建议:
|
||||
- 若继续本主题,恢复到 `MA0046` 主批次,不再停留在当前 PR follow-up
|
||||
- 若 PR review 还出现新线程,继续遵守“只修复当前本地仍成立的问题”的策略
|
||||
|
||||
## 2026-04-21 — RP-009
|
||||
|
||||
### 阶段:`MA0048` 批次收口(RP-009)
|
||||
|
||||
- 依据 `RP-008` 的批处理策略,本轮继续从 `GFramework.Core` 的 `MA0048` 启动,但不采用重命名公共类型的高风险做法;
|
||||
改为把同名不同泛型 arity 的家族收拢到与类型名一致的单文件中
|
||||
- 具体调整:
|
||||
- 将 `AbstractCommand<TInput>` 与 `AbstractCommand<TInput, TResult>` 合并进 `AbstractCommand.cs`
|
||||
- 将 `AbstractAsyncCommand<TInput>` 与 `AbstractAsyncCommand<TInput, TResult>` 合并进 `AbstractAsyncCommand.cs`
|
||||
- 将 `AbstractQuery<TInput, TResult>` 合并进 `AbstractQuery.cs`
|
||||
- 将 `AbstractAsyncQuery<TInput, TResult>` 合并进 `AbstractAsyncQuery.cs`
|
||||
- 将泛型 `Event<T>` / `Event<T, TK>` 从 `EasyEventGeneric.cs` 迁移到 `Event.cs`
|
||||
- 首次构建暴露出合并后的 `ICommand<TResult>` / `IQuery<TResult>` 命名空间歧义;随后改用
|
||||
`GFramework.Core.Abstractions.*` 的限定名完成最小修正,没有引入行为改动
|
||||
- 定向验证通过:
|
||||
- `dotnet build GFramework.Core/GFramework.Core.csproj -c Release -t:Rebuild --no-restore -p:UseSharedCompilation=false -p:TargetFramework=net8.0 -p:RestoreFallbackFolders="" -nologo -clp:"Summary;WarningsOnly"`
|
||||
- 结果:`15 Warning(s)`,`0 Error(s)`;`MA0048` 已从当前 `GFramework.Core` `net8.0` warnings-only 基线中清空
|
||||
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --filter "FullyQualifiedName~CommandExecutorTests|FullyQualifiedName~AbstractAsyncCommandTests|FullyQualifiedName~QueryExecutorTests|FullyQualifiedName~AbstractAsyncQueryTests|FullyQualifiedName~EventTests" -m:1 -p:RestoreFallbackFolders="" -nologo`
|
||||
- 结果:`83 Passed`,`0 Failed`
|
||||
- 当前建议的下一批次顺序更新为:
|
||||
- 第一优先级:`MA0046`
|
||||
- 第二优先级:`MA0016`
|
||||
- 顺手吸收:`MA0015`、`MA0077`
|
||||
- 单独评估:`MA0002`
|
||||
|
||||
## 2026-04-21 — RP-008
|
||||
|
||||
### 阶段:批处理策略切换(RP-008)
|
||||
|
||||
- 根据当前 `GFramework.Core` warnings-only build 的剩余分布,后续不再默认沿用“单文件、单 warning family”的切片节奏,
|
||||
改为按 warning 类型和数量优先级批量推进
|
||||
- 当前数量基线:
|
||||
- `MA0048 = 8`
|
||||
- `MA0046 = 6`
|
||||
- `MA0016 = 5`
|
||||
- `MA0002 = 2`
|
||||
- `MA0015 = 1`
|
||||
- `MA0077 = 1`
|
||||
- 新的批处理规则:
|
||||
- 先按类型选择主批次,而不是按单文件选切入点
|
||||
- 若主批次数量不够,则允许顺手并入其他低冲突类型;`MA0015` 与 `MA0077` 只是当前明显的低数量尾项示例,不是限定范围
|
||||
- 单次 `boot` 的工作树改动规模控制在约 `100` 个文件以内,避免 recovery context 和 review 面同时膨胀
|
||||
- 当 warning 类型或目录边界清晰且写集不冲突时,允许使用不同模型的 subagent 并行处理,但必须先定义独占 ownership
|
||||
- 当前建议的下一批次顺序:
|
||||
- 第一优先级:`MA0048`
|
||||
- 第二优先级:`MA0046`
|
||||
- 顺手吸收:其他低冲突类型,当前可见示例包括 `MA0015`、`MA0077`
|
||||
- 单独评估:`MA0016`、`MA0002`
|
||||
- 本轮仅更新 recovery strategy,不改生产代码;验证继续沿用当前基线构建:
|
||||
- `dotnet build GFramework.Core/GFramework.Core.csproj -c Release -t:Rebuild --no-restore -p:UseSharedCompilation=false -p:TargetFramework=net8.0 -p:RestoreFallbackFolders="" -p:RestorePackagesPath=<linux-nuget-cache> -nologo -clp:"Summary;WarningsOnly"`
|
||||
- 结果:`23 Warning(s)`,`0 Error(s)`
|
||||
|
||||
## 2026-04-21 — RP-007
|
||||
|
||||
### 阶段:CoroutineScheduler `MA0051` 收口(RP-007)
|
||||
|
||||
- 依据 active tracking 中“继续只选一个 `GFramework.Core` 结构性切入点”的约束,本轮选择
|
||||
`GFramework.Core/Coroutine/CoroutineScheduler.cs`,因为剩余两个 `MA0051` 都集中在协程启动与完成清理路径,且已有
|
||||
`CoroutineSchedulerTests`、`CoroutineSchedulerAdvancedTests` 覆盖句柄创建、取消、完成状态、标签分组和等待语义
|
||||
- 将 `Run` 拆分为:
|
||||
- `AllocateSlotIndex`
|
||||
- `CreateRunningSlot`
|
||||
- `RegisterCancellationCallback`
|
||||
- `RegisterStartedCoroutine`
|
||||
- `CreateCoroutineMetadata`
|
||||
- `ResetCompletionTracking`
|
||||
- 将 `FinalizeCoroutine` 拆分为:
|
||||
- `TryGetFinalizableCoroutine`
|
||||
- `UpdateCompletionMetadata`
|
||||
- `ApplyCompletionMetadata`
|
||||
- `ReleaseCompletedCoroutine`
|
||||
- `CompleteCoroutineLifecycle`
|
||||
- 保持取消回调只做跨线程入队、`Prewarm` 时机、统计记录文本、`RemoveTag` / `RemoveGroup` / `WakeWaiters` 顺序以及
|
||||
`OnCoroutineFinished` 的同步触发时机不变,只收缩主方法长度并补齐辅助方法意图注释
|
||||
- 验证通过:
|
||||
- `dotnet build GFramework.Core/GFramework.Core.csproj -c Release -t:Rebuild --no-restore -p:UseSharedCompilation=false -p:TargetFramework=net8.0 -p:RestoreFallbackFolders="" -p:RestorePackagesPath=<linux-nuget-cache> -nologo -clp:"Summary;WarningsOnly"`
|
||||
- 结果:`23 Warning(s)`,`0 Error(s)`;`CoroutineScheduler.cs` 已不再出现在 `MA0051` 列表
|
||||
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --filter FullyQualifiedName~CoroutineScheduler -p:RestoreFallbackFolders="" -p:RestorePackagesPath=<linux-nuget-cache> -nologo`
|
||||
- 结果:`34 Passed`,`0 Failed`
|
||||
- 当前 `MA0051` 主线已经在本主题下完成;下一步若继续,应先重新评估剩余 `MA0048`、`MA0046`、`MA0002`、`MA0016` 的
|
||||
收敛价值与改动风险,再决定是否开启下一轮 warning family
|
||||
|
||||
## 2026-04-21 — RP-006
|
||||
|
||||
### 阶段:Store `MA0051` 收口(RP-006)
|
||||
|
||||
- 依据 active tracking 中“继续只选一个 `GFramework.Core` 结构性切入点”的约束,本轮选择
|
||||
`GFramework.Core/StateManagement/Store.cs`,因为该文件的两个 `MA0051` 都集中在 dispatch / reducer snapshot 逻辑,
|
||||
且已有 `StoreTests` 覆盖 dispatch、batch、history 和多态 reducer 匹配语义
|
||||
- 在正式验证前先处理 WSL 环境噪音:当前 worktree 的 `GFramework.Core/obj/project.assets.json` 是 Windows 侧 restore
|
||||
产物,`--no-restore` 构建会继续引用宿主 Windows fallback package folder;本轮先执行一次 Linux 侧
|
||||
`dotnet restore GFramework.Core/GFramework.Core.csproj -p:RestoreFallbackFolders="" -p:RestorePackagesPath=<linux-nuget-cache> --ignore-failed-sources -nologo`
|
||||
刷新资产文件,再继续 warnings-only build
|
||||
- 将 `Dispatch` 拆分为:
|
||||
- `EnterDispatchScope`
|
||||
- `TryCommitDispatchResult`
|
||||
- `ExitDispatchScope`
|
||||
- 将 `CreateReducerSnapshotCore` 拆分为:
|
||||
- `CreateExactReducerSnapshot`
|
||||
- `CreateAssignableReducerSnapshot`
|
||||
- `CollectReducerMatches`
|
||||
- `CompareReducerMatch`
|
||||
- 保持 `_dispatchGate -> _lock` 的锁顺序、middleware 锁外执行、批处理通知折叠以及“精确类型 -> 基类 -> 接口 ->
|
||||
注册顺序”的 reducer 稳定排序语义不变,只收缩主方法长度并补齐辅助方法意图注释
|
||||
- 验证通过:
|
||||
- `dotnet build GFramework.Core/GFramework.Core.csproj -c Release -t:Rebuild --no-restore -p:UseSharedCompilation=false -p:TargetFramework=net8.0 -p:RestoreFallbackFolders="" -p:RestorePackagesPath=<linux-nuget-cache> -nologo -clp:"Summary;WarningsOnly"`
|
||||
- 结果:`25 Warning(s)`,`0 Error(s)`;`Store.cs` 已不再出现在 `MA0051` 列表
|
||||
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --filter FullyQualifiedName~StoreTests -p:RestoreFallbackFolders="" -p:RestorePackagesPath=<linux-nuget-cache> -nologo`
|
||||
- 结果:`30 Passed`,`0 Failed`
|
||||
- 下一步保持同一节奏:只在 `CoroutineScheduler.cs` 的 `Run` / `FinalizeCoroutine` 两个 `MA0051` 中继续,不与其他
|
||||
warning 家族混做
|
||||
|
||||
## 2026-04-21 — RP-005
|
||||
|
||||
### 阶段:PauseStackManager `MA0051` 收口(RP-005)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user