# 服务端运行中热更去锁(字节加载 + 内容指纹失效)实现计划 > **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 10(Server.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)。仓库根 `` = `D:\UD\AI\AIC#Project`,所有命令在 `` 下执行。基线:`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 只能依赖 BCL(System.*);若将来加第三方依赖,需同步扩展下面白名单。 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: 全 PASS(51 原有 + 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 _current = new Dictionary(); private readonly List _retired = new List(); 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: 全 PASS(52 + 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 ✓