第 15 周 · Tether 源码课

动态插件:替换代码前先收回旧引用

预计 100–120 分钟 先修:第 3–5 周:Plugin、EffectScope 与 Service activation、理解 object reference、GC 与 async disposal

学完你能做到

  • 解释 Compile、mount、unmount、Unload 与 GC reclaim 的区别
  • 按源码写出 HMR 成功、编译失败和 apply 失败三条路径
  • 找出 event handler、service、timer 与 static cache 如何钉住旧 ALC
  • 用 WeakReference 与 HMR tests 验证回收和 last-good rollback

课程进度

  1. 第 1 周
  2. 第 2 周
  3. 第 3 周
  4. 第 4 周
  5. 第 5 周
  6. 第 6 周
  7. 第 7 周
  8. 第 8 周
  9. 第 9 周
  10. 第 10 周
  11. 第 11 周
  12. 第 12 周
  13. 第 13 周
  14. 第 14 周
  15. 第 15 周
  16. 第 16 周

一句话先懂

动态插件能被替换,不是因为“DLL 可以删除”,而是因为旧插件先撤销所有活引用,再对 collectible AssemblyLoadContext 发出卸载请求。

Unload() 只说“可以回收了”;真正回收要等 GC 发现外部世界再也到不了旧程序集中的任何对象。

先看大图:一次带回滚材料的换班

旧插件运行中 Handle A + Script A 编译候选 Script B / new ALC 编译失败 A 从未卸载 · 报错 回收 Handle A EffectScope cleanup · Script A 暂留 Create + apply B ctx.Plugin → Completion apply 成功:提交 B Script A.Unload() 之后由 GC 决定何时 reclaim apply 失败 清理 Handle/Script B Script A.CreatePlugin() 恢复 A 或报告复合失败 last-good 是回滚,不是零空窗 关键:旧 Script A 要留到 B apply 成功;否则失败后没有旧类型可重新实例化。
编译失败时旧 handle 一直活着;apply 失败发生在旧 handle 已回收之后,因此实现会从仍加载的旧 assembly 重建插件。流程提供 last-good rollback,但不承诺无缝原子切换。

一个类比:剧场换演员

把一个动态插件想成舞台上的演员:

  1. 新演员先在后台读完剧本——对应 compile candidate。
  2. 旧演员退场并清走自己的道具——对应 PluginHandle.DisposeAsync() 回收 EffectScope。
  3. 旧剧本先留在后台——对应旧 CompiledScript 仍持有 assembly,便于失败时重建旧角色。
  4. 新演员顺利登台后,才把旧剧本送去销毁;新演员失败,就按旧剧本让旧角色重新登台。

类比边界:ALC 不是物理剧本,Unload() 也不会立刻销毁对象。apply 失败时旧服务可能短暂不可用;回滚还可能再次失败。collectible ALC 只提供卸载能力,不提供权限隔离,动态代码仍可调用 host 已暴露的 .NET API。

ScriptCompiler 做了哪四件事

从 src/Cordis.Scripting/ScriptCompiler.cs 只抓主线:

C# source
  → Roslyn CSharpCompilation
  → emit PE into MemoryStream
  → new AssemblyLoadContext(name, isCollectible: true)
  → LoadFromStream → CompiledScript

它还建立一条明确的 script contract:assembly 中必须恰好有一个 public、非抽象、实现 IPlugin 的类型,而且能用无参构造函数创建。零个或多个 candidate 都会得到 ScriptCompilationException。

编译引用来自 trusted platform assemblies 和 default context 已加载的 assemblies。这让 script 能看见 BCL、Cordis 与 host 已装载的 Tether 包;也再次说明 ALC 是生命周期边界,不是安全沙箱。

四个动作不要混为“卸载”

动作对象真正发生的事
handle.DisposeAsync()PluginHandle / EffectScope停止 activation,撤销 service、listener、child scope 与 disposer
script.DisposeAsync()CompiledScript调用该 collectible ALC 的 Unload(),只发出卸载请求
GC.Collect()managed heap查找不可达对象;测试可推动观察,生产代码不靠它保证时点
weak.IsAlive == falsetest observation证明测试持有的 ALC 不再可达,才算实际 reclaimed

顺序很重要:如果先对旧 script 请求 unload,却仍有旧 plugin instance 或 event delegate 活着,GC 仍不能回收;如果在 candidate apply 前就丢掉旧 script,失败后也无法创建 last-good plugin。

哪些引用会把旧代码“钉”在内存里

假设 OldPlugin 来自 collectible ALC,下列任一条引用链都足以阻止回收:

root Context → service instance → OldPlugin type
shared event bus → delegate.Target → OldPlugin instance
ThreadPool timer → callback closure → old assembly object
default-context static cache → Type / MethodInfo / old object
test stack frame local → CompiledScript.LoadContext

前三类通过 context.Provide、context.On、context.Effect(timer.Dispose) 绑定到 exact EffectScope,就能在 handle disposal 时切断。static cache 若不归 scope 管,Cordis 无法凭空找到它;这是动态插件必须避免的隐藏全局状态。

ScriptCompilerTests 特意把 mount + release 放进独立 helper。helper 返回后,plugin、handle 与 script 的局部变量退出 stack frame,测试才循环 GC 并检查 WeakReference。这不是测试花招,而是在演示“一个无意的 local 也属于 GC reachability”。

HotReloadWatcher 怎样守住 last-good

src/Cordis.Hmr/HotReloadWatcher.cs 用 FileSystemWatcher 收集 *.cs 变化,经 debounce 折叠编辑器的多次文件事件,再用 _operations semaphore 串行处理 mount / reload / unmount。

三条路径必须分别记:

编译失败

旧 handle 没有被碰。Watcher 报告 ScriptCompilationException,旧 service 继续工作。

apply 成功

先 dispose 旧 handle,让旧 scope 完整退出;再 mount candidate 并等待 Completion。成功后更新 _mounted,最后对旧 script 请求 unload。

apply 失败

候选 handle 若已创建就先 dispose,再 unload candidate script;随后从仍加载的 previous script CreatePlugin(),重新 mount last-good。原 apply failure 会报告给可选 Error sink 和 contained internal/error。

若旧 handle cleanup、candidate cleanup 或 previous restore 也失败,实现会保留多个 failure 的可见性,不把回滚失败伪装成普通编译错误。

动手实验:什么时候才算旧版本离场

实验目标:先预测 handle、script、service 和 WeakReference 的状态,再运行回收与回滚测试。

1. 预测

为下列四个 checkpoint 填表:旧 service 是否可见?旧 script 是否仍加载?candidate 是否需要 cleanup?

  1. 新源码编译失败后。
  2. 新源码编译成功、旧 handle 刚 dispose 后。
  3. candidate apply 成功、旧 script 调用 Unload() 后。
  4. candidate apply 失败、previous restore 完成后。

再回答:checkpoint 3 结束时能否立刻断言旧 ALC 已被 GC reclaim?

2. 运行 collectible ALC 测试

set -e
dotnet test tests/Cordis.Scripting.Tests/Cordis.Scripting.Tests.csproj \
  --filter "FullyQualifiedName~Collectible_context_is_reclaimed_after_release|FullyQualifiedName~Explicit_unmount_releases_the_context_while_the_parent_stays_alive"

第二个 test 特别重要:root Context 仍活着,只有 plugin handle 与 script 被释放,旧 ALC 仍应可回收。这证明 exact child scope 没把旧引用留在共享 Registry / Events 中。

3. 运行 HMR 三条路径

set -e
dotnet test tests/Cordis.Hmr.Tests/Cordis.Hmr.Tests.csproj \
  --filter "FullyQualifiedName~Reload_swaps_the_mounted_plugin|FullyQualifiedName~Broken_source_keeps_the_previous_version_and_reports|FullyQualifiedName~Failing_apply_keeps_the_previous_version_and_reports"

三个 test 依次观察 service value 从 1 变 2、坏源码后仍为 1、apply 抛错后恢复为 1。最后两个都检查 failure 可见,而不是只检查“程序没崩”。

4. 观察与解释

  • CompiledScript.DisposeAsync() 本身没有等待 GC;测试必须持有 weak reference 并反复推动 collection。
  • compile failure 不触碰旧 handle,所以没有恢复动作。
  • apply failure 先让 candidate scope 按失败语义 cleanup,再用旧 assembly 创建一个新的旧版本实例。
  • MountedFiles 仍只有一个 path,不代表内存中从未短暂存在两个 ALC。

如果把“调用 Unload()”写成“旧程序集已经消失”,你会漏掉最难的泄漏;如果把 rollback 写成“旧实例一直运行”,又会漏掉 handle 已退出、实例被重建的事实。

检查理解

1. 为什么不能在编译 candidate 前先 dispose 旧插件?

查看答案

编译完全可以失败。先停旧插件会把一个无害的语法错误变成服务中断;当前实现先编译,只有拿到 candidate script 后才开始 swap。

2. 为什么 apply candidate 前可以 dispose 旧 handle,却不能 dispose 旧 script?

查看答案

旧 handle 必须退出,避免两个版本同时提供同一 service;旧 script 则是 rollback material。candidate apply 失败时,Watcher 还要从旧 assembly 创建新的 last-good plugin instance。

3. 插件只用 context.On 注册 listener,是否还需要手写 listener cleanup?

查看答案

正常不需要。`context.On` 的 subscription 已绑定当前 EffectScope,handle disposal 会兜底摘除。绕过 Context 直接订阅共享对象,或把 delegate 放进 static cache,才会逃出这条回收链。

4. collectible ALC 能否阻止恶意 script 读文件或启动进程?

查看答案

不能。collectible 只描述未来能否卸载。ScriptCompiler 还主动引用 host 已加载的 assemblies;权限限制必须交给真正的 sandbox / process isolation,不可由 ALC 名称推断。

本周带走

  • unmount plugin、request ALC unload 与 GC reclaim 是三个不同 checkpoint。
  • HMR 先编译 candidate;swap 时暂留旧 assembly,成功后卸载,apply 失败则从它恢复 last-good。
  • EffectScope 能切断登记过的引用,隐藏的 static / timer / direct subscription 仍会让旧 ALC 留在内存。

下一周把整学期的方法合在一起:在个人分支沿 seam 添加一个小能力,并用测试证明它能正确接入、也能正确退出。细节可查动态插件与脚本编译和Hot Reload Watcher。

在 GitHub 上编辑此页