动态插件与脚本编译

把一段 C# 源码编译成程序集、装进可独立卸载的加载上下文,再交给 Cordis 当普通插件挂载。 对应实现:src/Cordis.Scripting/。

两个类型就是全部 API

var compiler = new ScriptCompiler();

await using var script = compiler.Compile(source);   // 编译 + 装载
var plugin = script.CreatePlugin();                  // 取出插件实例
var handle = ctx.Plugin(plugin);                     // 当普通插件挂载
成员语义
ScriptCompiler.Compile(source, assemblyName?)编译成一个全新的可收集程序集。省略名字时用 Cordis.Dynamic.{guid}。
CompiledScript.Assembly编译出的程序集,在 dispose 之前有效。
CompiledScript.LoadContext背后的可收集加载上下文,供卸载诊断用,按只读对待。
CompiledScript.CreatePlugin()实例化那个唯一的插件类型。
CompiledScript.DisposeAsync()请求卸载加载上下文。

脚本插件契约

编译出的程序集必须包含恰好一个公开、非抽象、实现 IPlugin 的类型,并且能无参构造。

// 一段合法的脚本插件源码
public sealed class GreeterPlugin : Cordis.Plugin
{
    protected override void Apply(Cordis.Context ctx)
    {
        ctx.On<string>("message", text => Console.WriteLine($"hello {text}"));
    }
}

不满足契约时抛 ScriptCompilationException,三种情形分别有明确消息:

  • 没有候选类型;
  • 有多个候选类型(消息里列出全部候选的全名);
  • 候选类型构造失败。

编译本身失败也走同一个异常,消息里逐条列出错误级诊断并带上行号。

脚本能看见宿主的类型

引用集合由两部分并起来:

  • 框架的受信任平台程序集(TPA),也就是 BCL 面;
  • 默认加载上下文里已经加载的每一个非动态、有物理路径的程序集。

第二条是关键:脚本因此能直接用 Cordis 本体以及宿主已加载的任何包,看到的类型与宿主完全一致,不需要额外配置引用。

编译选项固定为动态链接库、Debug 优化级别、允许 unsafe。

为什么用可收集 ALC 而不是 CSharp.Scripting

每次编译都新建一个 AssemblyLoadContext(isCollectible: true),程序集从内存流装入。这是为了让旧版本能被真正卸载。

这是一个既定的技术选型

不使用 Microsoft.CodeAnalysis.CSharp.Scripting:它把脚本程序集装进默认加载上下文, 而默认上下文无法卸载,热重载就此失去可能。因此这里选择 Roslyn 内存编译 加自管的可收集 ALC。

DisposeAsync() 调用的是 AssemblyLoadContext.Unload(),它只是发出卸载请求。真正回收发生在之后某次 GC 发现没有任何活引用指向该程序集时。

活引用包括的东西比直觉更多:插件实例、还挂着的事件处理器、静态缓存、定时器回调,任何一处残留都会让卸载失败。这正是 Cordis 注册即回收纪律存在的理由——插件通过 ctx 注册的一切都能随作用域回收,卸载才有可能成功。

卸载成功率是已知风险

ALC 卸载要求没有任何活引用指向旧程序集。纪律第一条是唯一保障, LoadContext 之所以暴露出来,就是为了让测试能断言回收确实发生了。

编译失败时的资源处理

装载阶段若出错,加载上下文会先被 Unload() 再抛异常,不会泄漏一个悬空的可收集上下文。编译阶段失败则根本不会创建上下文。

与热重载的关系

ScriptCompiler 只负责”源码 → 可卸载的插件”这一步,不监听文件、不做防抖、不管替换策略。把它接成一个持续生效的开发循环是 Hot Reload Watcher 的职责:Watcher 先编译候选,再回收旧 handle、尝试挂载候选;成功后请求卸载旧 ALC,apply 失败则用暂留的旧 assembly 恢复 last-good。

模型面不做动态插件 CRUD(#279 登记)

Cordis.Scripting 是载体,不是产品面:它把源码变成可卸载插件,不提供任何模型可调用的注册/启停 API,也没有”define/run/stop/undefine”这类工具。

上游 dsh 在 creator 模式里把插件管理收敛成两件事:agent 以 workspace 文件形式编写包与 Loader YAML patch,再用持久的 install_bundle 安装;模型面的生成代码 mutation 工作流与相关动态自检工具 API 已退役。Tether 跟随同一决定:

  • 插件/Bundle 的启停与安装只经持久管理面(Web 端的插件市场、插件项目专属工具 plugin_project、plugin_manager agent 工具与 tether plugin CLI),它们共享同一个持久安装 authority,文件真相在 profile(bundles: 有序栈与 cordis.patch.yml)。
  • 项目内代码定制走标准的插件项目工作流,以普通工作区文件夹进行读改,并通过专门的编排工具完成编译、打包与原位热应用。
  • Web 的 Cordis 控制台是人用的观察/诊断面,不构成第三套生命周期权威。

因此,在 Cordis.Scripting 之上新增面向模型的 mutation API 之前,必须先对照本条:在同一个会话里让 agent 改活组合的需求,应当表达为一次可审计的持久安装事务,而不是第二套插件生命周期。

下一步

在 GitHub 上编辑此页