Hot Reload Watcher(HMR)运行机制与实现细节
对应实现:src/Cordis.Hmr/HotReloadWatcher.cs 与 src/Cordis.Scripting/ScriptCompiler.cs
模块目标
HotReloadWatcher 的职责不是“监听文件”本身,而是把 *.cs 源码目录持续映射为运行中的 Cordis 插件状态。它要保证热更新可用,同时让编译或 apply 失败可见并尽量恢复 last-good。
- 启动即挂载已有源码文件。
- 变更后防抖重编译并执行插件 swap。
- 编译失败时旧版本继续运行;apply 失败时从旧 assembly 重建 last-good。
- 删除或重命名时及时卸载旧挂载。
运行时对象协作
| 对象 | 职责 |
|---|---|
FileSystemWatcher | 捕获 Changed / Created / Deleted / Renamed。 |
Timer + Dirty Set | 防抖聚合变更,避免一保存多编译。 |
ScriptCompiler | Roslyn 编译并装入可收集 ALC。 |
CompiledScript | 提供 CreatePlugin 与 Unload 生命周期。 |
Context.Plugin | 将新插件挂入 Cordis 激活图。 |
PluginHandle | 回收旧 scope,撤销旧插件副作用。 |
启动路径(StartAsync)
- 检查
IsRunning,避免重复启动。 - 先开启 watcher 事件,避免初始扫描期间漏掉文件变化。
- 按路径顺序枚举目录所有
*.cs,逐个执行 reload;启动失败则关闭 watcher。
顺序保证
监听先于扫描,避免“扫描完成、监听尚未开启”之间的竞态;初始文件仍按稳定路径顺序挂载。
防抖与脏集策略
文件事件不会立刻触发编译。路径先进入 _dirty(HashSet 去重),随后重置 Timer。只有静默窗口到期才执行 FlushAsync 批量 reload。
OnFileEvent -> _dirty.Add(path)
Timer.Change(debounce, Infinite)
FlushAsync -> foreach dirty file => ReloadAsync(file)
结果是保存高频抖动被折叠为一轮稳定重载。
ReloadAsync 的 swap 语义
每个文件先编译候选;拿到 candidate script 后才回收旧 handle,同时暂留旧 script 供失败回滚。这是 last-good swap,不是零空窗的原子替换:
读取源码时的重试保护
编辑器常见保存行为是 truncate + write,瞬时会出现 IOException。实现通过 ReadWithRetry 做短暂重试(最多 5 次,递增等待),降低“刚写完就读”的失败率。
Roslyn 与可收集 ALC
- 使用 CSharpCompilation 动态编译为内存程序集。
- 引用集合包含 BCL(TPA)+ 默认上下文已加载程序集。
- 每次编译都创建新的
AssemblyLoadContext(isCollectible: true)。 - 旧版本通过
Unload()请求卸载,实际回收由 GC 与引用可达性决定。
插件契约约束
脚本程序集必须“恰好一个”公开、非抽象、实现 IPlugin 的类型,并可无参构造。不满足(0 个或多个候选,或实例化失败)都会抛出异常并触发“失败保旧”。
Cordis 生命周期一致性
新插件挂载走 Context.Plugin;旧插件释放走 PluginHandle.DisposeAsync,其内部会回收对应 EffectScope。因此监听器、服务注册、disposer 等副作用会被统一撤销,符合“注册即回收”的核心纪律。
失败可观测性与系统韧性
- 触发可选
Error回调。 - 向
internal/error事件通道广播异常,来源标签形如hmr:{file}。 - watcher 不退出,继续接受后续修复变更。
删除与重命名处理
- Deleted:执行
UnmountAsync,移除插件与服务暴露。 - Renamed:先卸载旧路径,再把新路径视作 Created 进入重载。
行为验证点(测试覆盖)
- Start 时挂载所有现有文件。
- Reload 后服务可见值更新。
- 坏源码不会破坏已运行旧版本。
- candidate apply 失败会报告错误,并从旧 assembly 恢复 last-good service。
- Unmount 后服务与 mounted 集合同步清空。
- 真实文件写入能被 watcher 自动拾取并生效。
总结
HotReloadWatcher 将“开发期快速迭代”和“运行期稳定性”组合在一起:防抖减噪、swap 保守替换、失败保旧、scope 级回收、可收集 ALC 卸载,共同构成可持续的插件热更新闭环。
延伸阅读
热重载依赖的“注册即回收”纪律见 插件与 Context; 编译与 ALC 的细节见 动态插件与脚本编译。