第 15 周 · Tether 源码课
动态插件:替换代码前先收回旧引用
学完你能做到
- 解释 Compile、mount、unmount、Unload 与 GC reclaim 的区别
- 按源码写出 HMR 成功、编译失败和 apply 失败三条路径
- 找出 event handler、service、timer 与 static cache 如何钉住旧 ALC
- 用 WeakReference 与 HMR tests 验证回收和 last-good rollback
课程进度
- 第 1 周
- 第 2 周
- 第 3 周
- 第 4 周
- 第 5 周
- 第 6 周
- 第 7 周
- 第 8 周
- 第 9 周
- 第 10 周
- 第 11 周
- 第 12 周
- 第 13 周
- 第 14 周
- 第 15 周
- 第 16 周
一句话先懂
动态插件能被替换,不是因为“DLL 可以删除”,而是因为旧插件先撤销所有活引用,再对 collectible AssemblyLoadContext 发出卸载请求。
Unload() 只说“可以回收了”;真正回收要等 GC 发现外部世界再也到不了旧程序集中的任何对象。
先看大图:一次带回滚材料的换班
一个类比:剧场换演员
把一个动态插件想成舞台上的演员:
- 新演员先在后台读完剧本——对应 compile candidate。
- 旧演员退场并清走自己的道具——对应
PluginHandle.DisposeAsync()回收 EffectScope。 - 旧剧本先留在后台——对应旧
CompiledScript仍持有 assembly,便于失败时重建旧角色。 - 新演员顺利登台后,才把旧剧本送去销毁;新演员失败,就按旧剧本让旧角色重新登台。
类比边界: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 == false | test 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?
- 新源码编译失败后。
- 新源码编译成功、旧 handle 刚 dispose 后。
- candidate apply 成功、旧 script 调用
Unload()后。 - 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。