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

21 KiB
Raw Blame History

服务端运行中热更去锁(字节加载 + 内容指纹失效)实现计划

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 两处 LoadFromAssemblyPathFile.ReadAllBytes+LoadFromStream(无文件句柄);GameModuleRegistry 加内容指纹:Acquire 命中缓存时重算磁盘指纹,变化则旧条目退役(_retired,旧房间打完引用归零即卸载)、加载新内容。生产环境版本不可变、指纹恒命中,行为零变化。

Tech Stack: .NET 10Server.Host)、xUnit、复用 XWorld.PublishTool.Md5UtilServer.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

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: FAILIOException(文件正由另一进程使用)——Windows 上 LoadFromAssemblyPath 锁定 DLL。

  • Step 1.3: 实现字节加载 —— GameModuleAlc.cs 整文件替换为:
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 新 = 52AlcUnloadTests 的弱引用 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

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_* 三个 FAILAssert.NotSame 失败——旧实现不看内容)。

  • Step 2.3: 实现 —— GameModuleRegistry.cs 整文件替换为:
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 注意事项——把

注意:网关运行中且某版本已被房间加载时,**覆盖重发同版本**会因 DLL 文件锁失败——
运行中更新一律发新版本号(新版本自动淘汰旧版本,旧房间打完即卸载)。

改为:

**同版本覆盖重发也支持**:服务器 DLL 为字节加载(不锁文件),覆盖落位总能成功;
网关按磁盘内容指纹自动失效——下一个新房间即用新代码,已入房玩家打完旧代码后旧模块自动卸载。
(生产环境仍建议版本号递增,便于客户端 AB 与服务器 DLL 的版本对应关系可追溯。)
  • Step 3.2: 终验

Run:

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/_retiredComputeFingerprintOrNull 在测试与实现间一致;Md5Util 来自 Server.Host 已有的 PublishTool 引用(GameModuleLoader.VerifySignature 已在用)✓
  • 测试隔离:全部改写文件的测试用临时 gamesRoot 拷贝,不碰共享 TestGames.Root
  • 无 gitcommit 步骤替换为验证 checkpoint ✓