Initial commit: Client Doc docs Server Tools

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ud18010
2026-07-10 10:24:29 +08:00
co-authored by Cursor
commit 7e35d8da31
3374 changed files with 680813 additions and 0 deletions
@@ -0,0 +1,484 @@
# 服务端运行中热更去锁(字节加载 + 内容指纹失效)实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 同版本覆盖重发不再被文件锁挡住且发布即生效——DLL 字节加载(不锁文件)+ Registry 按磁盘内容指纹自动失效重载。
**Architecture:** `GameModuleAlc` 两处 `LoadFromAssemblyPath``File.ReadAllBytes`+`LoadFromStream`(无文件句柄);`GameModuleRegistry` 加内容指纹:`Acquire` 命中缓存时重算磁盘指纹,变化则旧条目退役(`_retired`,旧房间打完引用归零即卸载)、加载新内容。生产环境版本不可变、指纹恒命中,行为零变化。
**Tech Stack:** .NET 10Server.Host)、xUnit、复用 `XWorld.PublishTool.Md5Util`Server.Host 已引用 PublishTool)。
**Spec:** `docs/superpowers/specs/2026-07-07-minigame-publish-pipeline-design.md` §4.3.2
**约定:无 git(不提交,commit 步骤替换为验证 checkpoint)。仓库根 `<repo>` = `D:\UD\AI\AIC#Project`,所有命令在 `<repo>` 下执行。基线:`dotnet test Server/Server.sln` = 182 通过 / 0 失败(若 `Server/Server.Host.Tests/TestGames/` 不存在,先跑 `bash Server/build-test-games.sh`)。测试注意:`TestGames.Root` 是共享只读夹具——**涉及改写文件的测试必须先拷到临时 gamesRoot,不许原地改**。**
---
### Task 1: GameModuleAlc 字节加载(去文件锁),TDD
**Files:**
- Create: `Server/Server.Host.Tests/ModuleFileLockTests.cs`
- Modify: `Server/Server.Host/GameModuleAlc.cs`
- [ ] **Step 1.1: 写失败测试** `ModuleFileLockTests.cs`
```csharp
using System;
using System.IO;
using Xunit;
using XWorld.Server.Host;
namespace XWorld.Server.Host.Tests
{
// 字节加载去锁:模块加载期间磁盘 DLL 可被覆盖/删除(同版本覆盖重发的前提)
public class ModuleFileLockTests
{
// 把共享只读夹具 rps/1 拷到独立临时 gamesRoot(不许改 TestGames.Root 原件)
private static string CopyRps1ToTempRoot()
{
string root = Path.Combine(Path.GetTempPath(), "games_" + Guid.NewGuid().ToString("N"));
string dst = Path.Combine(root, "rps", "1");
Directory.CreateDirectory(dst);
string src = Path.Combine(TestGames.Root, "rps", "1");
foreach (var f in Directory.GetFiles(src))
File.Copy(f, Path.Combine(dst, Path.GetFileName(f)));
return root;
}
[Fact]
public void LoadedModule_DoesNotLockDlls_OverwriteAndDeleteSucceed()
{
string root = CopyRps1ToTempRoot();
var module = new GameModuleLoader().Load(root, "rps", 1);
try
{
string serverDll = Path.Combine(root, "rps", "1", "RPS.Server.dll");
string coreDll = Path.Combine(root, "rps", "1", "RPS.Core.dll");
// 加载期间覆盖写入 —— LoadFromAssemblyPath 会锁文件抛 IOException,字节加载应成功
byte[] original = File.ReadAllBytes(serverDll);
File.WriteAllBytes(serverDll, original);
// 加载期间删除
File.Delete(coreDll);
Assert.False(File.Exists(coreDll));
// 模块本体仍可用(IL 已在内存)
var room = module.CreateRoom();
Assert.NotNull(room);
}
finally
{
module.Unload();
try { Directory.Delete(root, true); } catch { }
}
}
}
}
```
- [ ] **Step 1.2: 跑测试确认失败**
Run: `cd "D:/UD/AI/AIC#Project" && dotnet test Server/Server.Host.Tests -nologo --filter ModuleFileLockTests 2>&1 | tail -6`
Expected: FAIL`IOException`(文件正由另一进程使用)——Windows 上 `LoadFromAssemblyPath` 锁定 DLL。
- [ ] **Step 1.3: 实现字节加载** —— `GameModuleAlc.cs` 整文件替换为:
```csharp
using System.IO;
using System.Reflection;
using System.Runtime.Loader;
namespace XWorld.Server.Host
{
// 可卸载上下文:框架契约走默认上下文(类型标识跨界一致),小游戏私有 DLL 载入本上下文。
// DLL 一律字节加载(LoadFromStream):不持有文件句柄,同版本覆盖重发不被文件锁挡住。
// 代价:字节加载的 Assembly.Location 为空——小游戏 DLL 不得依赖它。
internal sealed class GameModuleAlc : AssemblyLoadContext
{
private readonly string _moduleDir;
public GameModuleAlc(string name, string moduleDir) : base(name, isCollectible: true)
{
_moduleDir = moduleDir;
}
protected override Assembly Load(AssemblyName assemblyName)
{
// 设计 §4.2:框架契约类型必须在默认 ALC,不随小游戏卸载。
// 约束:XWorld.Framework.Shared 只能依赖 BCLSystem.*);若将来加第三方依赖,需同步扩展下面白名单。
if (assemblyName.Name == "XWorld.Framework.Shared")
return null;
// BCL / 运行时程序集始终走默认上下文,防止游戏目录里同名文件遮蔽(类型标识错乱)
string n = assemblyName.Name;
if (n != null && (n.StartsWith("System.", System.StringComparison.Ordinal) ||
n.StartsWith("Microsoft.", System.StringComparison.Ordinal) ||
n == "System" || n == "netstandard" || n == "mscorlib"))
return null;
// 小游戏私有程序集放在版本目录里,载入本可卸载上下文
string candidate = Path.Combine(_moduleDir, assemblyName.Name + ".dll");
if (File.Exists(candidate))
return LoadBytes(candidate);
return null; // 其余交默认上下文
}
public Assembly LoadEntry(string serverDllPath) => LoadBytes(serverDllPath);
private Assembly LoadBytes(string path)
{
using (var ms = new MemoryStream(File.ReadAllBytes(path)))
return LoadFromStream(ms);
}
}
}
```
- [ ] **Step 1.4: 跑测试确认通过 + Server.Host.Tests 全量**
Run: `dotnet test Server/Server.Host.Tests -nologo 2>&1 | tail -4`
Expected: 全 PASS51 原有 + 1 新 = 52`AlcUnloadTests` 的弱引用 GC 断言对 LoadFromStream 同样成立)。
---
### Task 2: GameModuleRegistry 内容指纹自动失效,TDD
**Files:**
- Create: `Server/Server.Host.Tests/RegistryContentInvalidationTests.cs`
- Modify: `Server/Server.Host/GameModuleRegistry.cs`(整文件替换)
- [ ] **Step 2.1: 写失败测试** `RegistryContentInvalidationTests.cs`
```csharp
using System;
using System.IO;
using System.Runtime.CompilerServices;
using Xunit;
using XWorld.Server.Host;
namespace XWorld.Server.Host.Tests
{
// 同 (gameId,version) 磁盘内容变化(覆盖重发)→ Registry 自动失效重载;
// 旧房间打完旧代码(退役条目引用归零即卸载),新房间用新代码。
public class RegistryContentInvalidationTests
{
private static string CopyRps1ToTempRoot()
{
string root = Path.Combine(Path.GetTempPath(), "games_" + Guid.NewGuid().ToString("N"));
string dst = Path.Combine(root, "rps", "1");
Directory.CreateDirectory(dst);
string src = Path.Combine(TestGames.Root, "rps", "1");
foreach (var f in Directory.GetFiles(src))
File.Copy(f, Path.Combine(dst, Path.GetFileName(f)));
return root;
}
// 改变内容指纹(game.json 在指纹范围内;追加空白不影响 JSON 解析)
private static void TouchContent(string root)
{
string gameJson = Path.Combine(root, "rps", "1", "game.json");
File.AppendAllText(gameJson, " ");
}
[Fact]
public void SameContent_ReturnsCachedInstance()
{
string root = CopyRps1ToTempRoot();
var reg = new GameModuleRegistry(new GameModuleLoader(), root);
var a = reg.Acquire("rps", 1);
var b = reg.Acquire("rps", 1);
Assert.Same(a, b);
Assert.Equal(2, reg.RefCount("rps", 1));
}
[Fact]
public void ContentChange_AfterRefZero_ReloadsNewInstance()
{
string root = CopyRps1ToTempRoot();
var reg = new GameModuleRegistry(new GameModuleLoader(), root);
var a = reg.Acquire("rps", 1);
reg.Release(a); // 引用归零,未淘汰 → 保留缓存
TouchContent(root);
var b = reg.Acquire("rps", 1);
Assert.NotSame(a, b); // 内容变了 → 新模块实例
Assert.Equal(1, reg.RefCount("rps", 1));
Assert.True(reg.IsLoaded("rps", 1));
}
[Fact]
public void ContentChange_WhileOldActive_OldAndNewCoexist_ReleaseBothCorrect()
{
string root = CopyRps1ToTempRoot();
var reg = new GameModuleRegistry(new GameModuleLoader(), root);
var oldM = reg.Acquire("rps", 1); // 旧房间在打
TouchContent(root);
var newM = reg.Acquire("rps", 1); // 新房间 → 新内容
Assert.NotSame(oldM, newM);
Assert.Equal(1, reg.RefCount("rps", 1)); // RefCount 只统计当前条目
reg.Release(oldM); // 旧房间结束:退役条目按实例识别释放,不抛
reg.Release(newM);
Assert.Equal(0, reg.RefCount("rps", 1));
Assert.True(reg.IsLoaded("rps", 1)); // 当前条目未被淘汰 → 保留复用
}
[Fact]
public void ContentChange_RetiredModule_UnloadsAndReclaims()
{
string root = CopyRps1ToTempRoot();
var weak = AcquireChangeReleaseOld(root);
for (int i = 0; i < 12 && weak.IsAlive; i++)
{
GC.Collect();
GC.WaitForPendingFinalizers();
}
Assert.False(weak.IsAlive, "退役模块的 ALC 未被回收 —— 存在泄漏引用");
}
// 独立方法确保局部强引用随返回消失(同 AlcUnloadTests 套路)
[MethodImpl(MethodImplOptions.NoInlining)]
private static WeakReference AcquireChangeReleaseOld(string root)
{
var reg = new GameModuleRegistry(new GameModuleLoader(), root);
var oldM = reg.Acquire("rps", 1);
TouchContent(root);
var newM = reg.Acquire("rps", 1); // oldM 退役
var weak = new WeakReference(oldM);
reg.Release(oldM); // 退役 + 归零 → 卸载
reg.Release(newM);
return weak;
}
[Fact]
public void MissingDir_ServesCachedEntry()
{
string root = CopyRps1ToTempRoot();
var reg = new GameModuleRegistry(new GameModuleLoader(), root);
var a = reg.Acquire("rps", 1);
string dir = Path.Combine(root, "rps", "1");
string away = dir + ".away";
Directory.Move(dir, away); // 模拟覆盖交换的瞬间窗口
try
{
var b = reg.Acquire("rps", 1); // 指纹算不出 → 用缓存兜底
Assert.Same(a, b);
}
finally { Directory.Move(away, dir); }
}
}
}
```
- [ ] **Step 2.2: 跑测试确认失败**
Run: `dotnet test Server/Server.Host.Tests -nologo --filter RegistryContentInvalidationTests 2>&1 | tail -6`
Expected: `SameContent/MissingDir` 可能通过(旧实现即缓存),`ContentChange_*` 三个 FAIL`Assert.NotSame` 失败——旧实现不看内容)。
- [ ] **Step 2.3: 实现** —— `GameModuleRegistry.cs` 整文件替换为:
```csharp
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
namespace XWorld.Server.Host
{
// (gameId,version) -> 已加载模块;按引用计数获取/释放。
// 淘汰有两个来源:①同 gameId 更高版本加载成功;②同 (gameId,version) 磁盘内容指纹变化(覆盖重发)。
// 被淘汰且引用归零即卸载;内容变化时新旧模块并存至旧房间结束(_retired,Release 按实例识别)。
// 生产环境版本不可变、指纹恒命中缓存,行为与旧实现一致。
public sealed class GameModuleRegistry
{
private sealed class Entry
{
public LoadedGameModule Module;
public int RefCount;
public bool Superseded;
public string Fingerprint;
}
private readonly GameModuleLoader _loader;
private readonly string _gamesRoot;
private readonly Dictionary<string, Entry> _current = new Dictionary<string, Entry>();
private readonly List<Entry> _retired = new List<Entry>();
public GameModuleRegistry(GameModuleLoader loader, string gamesRoot)
{
_loader = loader; _gamesRoot = gamesRoot;
}
private static string Key(string gameId, int version) => $"{gameId}@{version}";
private string ModuleDir(string gameId, int version) => Path.Combine(_gamesRoot, gameId, version.ToString());
public LoadedGameModule Acquire(string gameId, int version)
{
string key = Key(gameId, version);
if (_current.TryGetValue(key, out var e))
{
// 覆盖重发检测:磁盘内容指纹变化 → 旧条目退役、加载新内容。
// 指纹算不出(目录处于覆盖交换的瞬间窗口/被删)→ 视为未变化,用缓存兜底。
string fp = ComputeFingerprintOrNull(ModuleDir(gameId, version));
if (fp == null || fp == e.Fingerprint)
{
e.RefCount++;
return e.Module;
}
var reloaded = LoadEntry(gameId, version); // 失败直接抛出:旧条目原样保留,不污染状态
Retire(e);
_current[key] = reloaded;
SupersedeOlderVersions(gameId, version);
reloaded.RefCount++;
return reloaded.Module;
}
var loaded = LoadEntry(gameId, version);
_current[key] = loaded;
SupersedeOlderVersions(gameId, version);
loaded.RefCount++;
return loaded.Module;
}
public void Release(LoadedGameModule module)
{
string key = Key(module.GameId, module.Version);
if (_current.TryGetValue(key, out var e) && ReferenceEquals(e.Module, module))
{
if (e.RefCount <= 0)
throw new InvalidOperationException($"重复释放模块 {module.GameId}@{module.Version}");
e.RefCount--;
if (e.RefCount <= 0 && e.Superseded)
{
_current.Remove(key);
module.Unload();
}
return;
}
// 退役条目:同版本被覆盖重发淘汰后仍被旧房间持有,按实例识别
for (int i = 0; i < _retired.Count; i++)
{
if (!ReferenceEquals(_retired[i].Module, module)) continue;
var r = _retired[i];
if (r.RefCount <= 0)
throw new InvalidOperationException($"重复释放模块 {module.GameId}@{module.Version}");
r.RefCount--;
if (r.RefCount <= 0)
{
_retired.RemoveAt(i);
module.Unload();
}
return;
}
// 未知模块的释放忽略(与旧实现对不存在 key 直接 return 的行为一致)
}
public int RefCount(string gameId, int version)
=> _current.TryGetValue(Key(gameId, version), out var e) ? e.RefCount : 0;
public bool IsLoaded(string gameId, int version) => _current.ContainsKey(Key(gameId, version));
private Entry LoadEntry(string gameId, int version)
{
// 指纹在加载前采样:加载成功即与所载内容对应(单线程 tick actor 下无并发写窗口)
string fp = ComputeFingerprintOrNull(ModuleDir(gameId, version));
var loaded = _loader.Load(_gamesRoot, gameId, version);
return new Entry { Module = loaded, RefCount = 0, Superseded = false, Fingerprint = fp };
}
private void SupersedeOlderVersions(string gameId, int version)
{
foreach (var kv in _current)
if (kv.Value.Module.GameId == gameId && kv.Value.Module.Version < version)
kv.Value.Superseded = true;
}
private void Retire(Entry e)
{
e.Superseded = true;
if (e.RefCount <= 0) e.Module.Unload();
else _retired.Add(e);
}
// 目录内容指纹:全部 *.dll + game.json 按名 Ordinal 排序拼 name=md5; 再整体 md5
// 读不到(目录不存在/IO 竞态)返回 null 由调用方兜底
private static string ComputeFingerprintOrNull(string dir)
{
try
{
if (!Directory.Exists(dir)) return null;
var names = Directory.GetFiles(dir, "*.dll")
.Select(Path.GetFileName)
.Append("game.json")
.Where(n => File.Exists(Path.Combine(dir, n)))
.Distinct()
.OrderBy(n => n, StringComparer.Ordinal);
var sb = new StringBuilder();
foreach (var n in names)
sb.Append(n).Append('=')
.Append(XWorld.PublishTool.Md5Util.OfFile(Path.Combine(dir, n))).Append(';');
return XWorld.PublishTool.Md5Util.OfBytes(Encoding.UTF8.GetBytes(sb.ToString()));
}
catch (IOException) { return null; }
catch (UnauthorizedAccessException) { return null; }
}
}
}
```
- [ ] **Step 2.4: 跑新测试 + Server.Host.Tests 全量**
Run: `dotnet test Server/Server.Host.Tests -nologo 2>&1 | tail -4`
Expected: 全 PASS52 + 5 新 = 57)。既有 `GameModuleRegistryTests` 5 个必须原样通过(`Acquire_SameVersionTwice`/`Release_ToZero`/`NewVersion_Supersedes`/`FailedNewVersionLoad`/`Release_MoreThanAcquired`——公共 API 与语义未变)。
- [ ] **Step 2.5: Checkpoint——全解决方案回归**
Run: `dotnet test Server/Server.sln -nologo 2>&1 | tail -8`
Expected: 0 失败(182 基线 + 6 新 = 188 左右;Gateway 的 RoomHost/ServerLoop 只用 Acquire/Release,零改动)。
---
### Task 3: 文档同步 + 终验
**Files:**
- Modify: `Tools/PcMiniGameSmoke/README.md`(「运行中热更」小节)
- [ ] **Step 3.1: 改 README 注意事项**——把
```markdown
注意:网关运行中且某版本已被房间加载时,**覆盖重发同版本**会因 DLL 文件锁失败——
运行中更新一律发新版本号(新版本自动淘汰旧版本,旧房间打完即卸载)。
```
改为:
```markdown
**同版本覆盖重发也支持**:服务器 DLL 为字节加载(不锁文件),覆盖落位总能成功;
网关按磁盘内容指纹自动失效——下一个新房间即用新代码,已入房玩家打完旧代码后旧模块自动卸载。
(生产环境仍建议版本号递增,便于客户端 AB 与服务器 DLL 的版本对应关系可追溯。)
```
- [ ] **Step 3.2: 终验**
Run:
```bash
cd "D:/UD/AI/AIC#Project"
dotnet test Server/Server.sln -nologo 2>&1 | tail -8
```
Expected: 0 失败。
- [ ] **Step 3.3: 人工核对项(交给用户)**:网关运行中(Editor Play 或命令行起 `Gateway.Runner --gamesRoot deploy/minigame`),菜单对同版本"覆盖重发"RPS——落位成功(无文件锁报错);不重启网关再匹配进一局,日志可见模块重新加载(新 ALC)。
---
## Self-Review 记录
- **Spec 覆盖**:§4.3.2 字节加载(Task 1)、指纹失效/退役并存/Release 按实例(Task 2)、README 语义更新(Task 3)✓
- **占位符**:无;每步含完整代码/命令 ✓
- **类型一致性**`Entry.Fingerprint``_current/_retired``ComputeFingerprintOrNull` 在测试与实现间一致;`Md5Util` 来自 Server.Host 已有的 PublishTool 引用(`GameModuleLoader.VerifySignature` 已在用)✓
- **测试隔离**:全部改写文件的测试用临时 gamesRoot 拷贝,不碰共享 `TestGames.Root`
- **无 git**commit 步骤替换为验证 checkpoint ✓