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防抖聚合变更,避免一保存多编译。
ScriptCompilerRoslyn 编译并装入可收集 ALC。
CompiledScript提供 CreatePlugin 与 Unload 生命周期。
Context.Plugin将新插件挂入 Cordis 激活图。
PluginHandle回收旧 scope,撤销旧插件副作用。

启动路径(StartAsync)

  1. 检查 IsRunning,避免重复启动。
  2. 先开启 watcher 事件,避免初始扫描期间漏掉文件变化。
  3. 按路径顺序枚举目录所有 *.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,不是零空窗的原子替换:

文件变更 watcher 防抖聚合 脏集去重 编译 新 ALC 回收旧 Handle 旧 Script 暂留 挂载候选 成功后卸载旧 ALC 编译失败 apply 失败 编译失败保留 A;apply 失败重建 A 清理 candidate,报告错误,继续监听 旧 Handle 在 candidate apply 前退出;旧 Script 留到成功提交,因此可以 rollback,但可能有短暂服务空窗。
compile failure 不触碰旧实例;apply failure 则清理 candidate,再从旧 assembly 创建一个新的旧版本实例。两条“保旧”路径并不相同。

读取源码时的重试保护

编辑器常见保存行为是 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 的细节见 动态插件与脚本编译。

在 GitHub 上编辑此页