Files
AIC-Project/docs/superpowers/plans/2026-07-07-runtime-hot-reload-unlock.md
2026-07-10 10:24:29 +08:00

485 lines
21 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 服务端运行中热更去锁(字节加载 + 内容指纹失效)实现计划
> **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 ✓