Events
一棵 Context 树共享一个 EventService。插件通常通过 Context 订阅,
使监听器自动归当前 EffectScope 所有;五种普通派发模式与 dsh Cordis 保持相同的顺序和 bail 语义。
对照基准是 dsh dsh-v0.1.1-rc.1 的
Events 参考页 与
events.ts。
C# 实现见 Events.cs 和
Context.cs。
TS → C# 逐项对照
| TypeScript | C# | 状态与差异 |
|---|---|---|
ctx.parallel(name, ...args) | ParallelAsync(name, args) | 对应。并发启动并等待全部 settled;C# 把全部失败包装为一个 AggregateException。 |
ctx.emit(name, ...args) | EmitAsync(name, args) | 近似。都按注册顺序忽略返回值;TS API 同步,C# 会等待每个 ValueTask。 |
ctx.serial(name, ...args) | SerialAsync(name, args) | 对应。顺序等待,返回首个 bail 值。 |
ctx.bail(name, ...args) | BailAsync(name, args) | 近似。C# 监听器统一可异步,所以该入口也返回 ValueTask;当前与 SerialAsync 派发语义相同。 |
ctx.waterfall(name, ...args) | WaterfallAsync(name, terminal, args) | 对应。C# 把最内层行为单独作为 terminal 参数,监听器通过 OnWaterfall 接收 next。 |
ctx.on(name, listener, options?) | On(name, listener, prepend) | 对应。Context 订阅随 scope 回收;返回的 IDisposable 可提前摘除。 |
ctx.once(name, listener, options?) | Once(name, listener, prepend) | 近似(比 dsh 更严格)。两边都在首次调用前摘除,失败也不会再次运行;并发 dispatch 下 C# 以原子认领保证至多调用一次,输掉认领的 dispatch 视同该 listener 不存在——上游 cordis 的重叠异步 dispatch 可能各调一次,这是有意的收紧。 |
EventOptions.prepend | bool prepend = false | 对应。 |
EventOptions.global | 无 | TS-only。C# 没有 Context listener filter,因此也没有 global 旁路。 |
DispatchMode union | 无枚举 | 语言差异。C# 用五个具名方法表达策略。 |
thisArg 派发重载 | 无 | TS-only 语法设施。C# listener 不通过动态 this 绑定。 |
订阅 API
Context.On:默认入口
public IDisposable On(string name, Action listener, bool prepend = false);
public IDisposable On(string name, Func<ValueTask> listener, bool prepend = false);
public IDisposable On<T1>(string name, Action<T1> listener, bool prepend = false);
public IDisposable On<T1>(string name, Func<T1, ValueTask> listener, bool prepend = false);
public IDisposable On<T1, T2>(string name, Action<T1, T2> listener, bool prepend = false);
public IDisposable On<T1, T2>(string name, Func<T1, T2, ValueTask> listener, bool prepend = false);
public IDisposable On<TResult>(string name, Func<ValueTask<TResult>> listener, bool prepend = false);
public IDisposable On<T1, TResult>(string name, Func<T1, ValueTask<TResult>> listener, bool prepend = false);
public IDisposable On<T1, TResult>(string name, Func<T1, TResult> listener, bool prepend = false);
每个重载最终登记到根 EventService,同时把取消订阅动作挂到当前 scope。返回值只是提前摘除手段;
即使插件没有保存它,scope disposal 仍会兜底。
prepend: true 把监听器插到同名事件已有监听器之前。多个 prepend 注册之间,后注册者会排在更前面。
Context.Once
public IDisposable Once(string name, Action listener, bool prepend = false);
public IDisposable Once(string name, Func<ValueTask> listener, bool prepend = false);
public IDisposable Once<T1>(string name, Action<T1> listener, bool prepend = false);
public IDisposable Once<T1>(string name, Func<T1, ValueTask> listener, bool prepend = false);
one-shot hook 在调用 listener 之前从总线移除。因此 listener 递归派发同名事件、异步失败或被取消时,
都不会看到自己第二次运行。并发 dispatch 可能都已把同一 once hook 纳入各自快照,仅靠摘除无法阻止第二次调用;
Tether 以原子认领(Interlocked compare-exchange)保证只有一个 dispatch 调用它。输掉认领的 dispatch 视同该
listener 不存在:emit / parallel 跳过它,serial / bail 不把它计为命中,waterfall 直接进入下一个 listener 或
terminal,也不会为它产生 trace 记录。这比 dsh Cordis 更严格(上游重叠的异步 dispatch 可能各调一次),是有意的偏离。
需要其它形状的一次性 raw listener,可直接使用 ctx.Events.Once(...),
但调用方必须负责保存并 dispose 返回的订阅。
Waterfall listener
public IDisposable OnWaterfall<TResult>(
string name,
Func<Func<ValueTask<TResult>>, ValueTask<TResult>> listener,
bool prepend = false);
public IDisposable OnWaterfall<T1, TResult>(
string name,
Func<T1, Func<ValueTask<TResult>>, ValueTask<TResult>> listener,
bool prepend = false);
public IDisposable OnWaterfall<T1, T2, TResult>(
string name,
Func<T1, T2, Func<ValueTask<TResult>>, ValueTask<TResult>> listener,
bool prepend = false);
TS 把 next 放在派发参数的最后一位;C# 用专门的 OnWaterfall 重载把 continuation 类型化。
监听器只有显式调用 next() 才会进入后续 listener 和 terminal。
Off 与 HasListeners
public void Context.Off(string name, Delegate listener);
public void EventService.Off(string name, Delegate listener);
public bool EventService.HasListeners(string name);
Off 移除同名事件下用同一个 delegate 实例注册的全部 hook。通常优先 dispose On 返回值,
因为它只摘除那一次注册;Off 更适合确实保留了原始 delegate 的兼容场景。
HasListeners 是 C# 专有查询。它只回答当前快照是否非空,不构成后续派发仍有 listener 的并发保证。
Context.On 与 EventService.On
EventService 还公开一个 raw 入口及与 Context 相同的类型化重载:
public IDisposable EventService.On(
string name,
Func<object?[], ValueTask<object?>> listener,
bool prepend = false);
public IDisposable EventService.Once(
string name,
Func<object?[], ValueTask<object?>> listener,
bool prepend = false);
直接订阅 ctx.Events 不会自动归当前 scope 所有。框架内部需要 raw 参数数组时可以使用它;插件代码应首选
ctx.On / ctx.Once / ctx.OnWaterfall。这是 C# 显式对象模型与 TS “事件方法混入 ctx”之间最重要的生命周期区别。
事件名仍是字符串。泛型重载约束 listener 形状,但派发端最终传递 object?[],不会像 TS 的 Events
声明合并那样把事件名与参数签名绑定成一个全局类型映射;产品包应以常量类统一事件名。
五种普通派发模式
| C# API | 执行方式 | bail | 失败行为 |
|---|---|---|---|
EmitAsync | 注册顺序逐个等待,忽略返回值 | 不适用 | 首个失败直接传播,后续 listener 不运行。 |
ParallelAsync | 全部 listener 并发开始,全部 settled 后返回 | 不适用 | 等完所有 listener,再以 AggregateException 抛出全部失败。 |
SerialAsync | 注册顺序逐个等待 | 首个非 null 且非 false | 失败处停止并直接传播。 |
BailAsync | 注册顺序逐个等待 | 首个非 null 且非 false | 失败处停止并直接传播。 |
WaterfallAsync | listener 嵌套在 terminal 外层 | 不调用 next() 即短路 | 下游失败可被外层捕获;未处理时传播给调用方。 |
EmitAsync
public ValueTask EmitAsync(string name, params object?[] args);
相较 TS 的同步 emit(),C# 会等待同步或异步 listener 完成,所以不会遗留未观察的 ValueTask。
普通派发绝不自动把 listener 异常送往 internal/error。
ParallelAsync
public ValueTask ParallelAsync(string name, params object?[] args);
每个 listener 都会运行,即使另一个 listener 同步抛错。所有结果 settle 后,零失败正常返回;一个或多个失败都包装为
AggregateException("One or more event listeners failed.", failures)。
SerialAsync 与 BailAsync
public ValueTask<object?> SerialAsync(string name, params object?[] args);
public ValueTask<object?> BailAsync(string name, params object?[] args);
public ValueTask<TResult?> BailAsync<TResult>(string name, params object?[] args)
where TResult : class;
当前两种非泛型入口采用同一 awaited dispatch 语义;保留两个名字是为了与 Cordis 词汇对齐。
false 表示“尚未处理”,继续寻找;0、空字符串和其它非 null 值都会 bail。
泛型重载只增加结果 cast,类型不匹配时抛 InvalidCastException。
WaterfallAsync
public ValueTask<TResult> WaterfallAsync<TResult>(
string name,
Func<ValueTask<TResult>> terminal,
params object?[] args);
ctx.OnWaterfall<Request, Response>(
"request/dispatch",
async (request, next) =>
{
if (!request.Allowed)
{
return Response.Denied;
}
return await next();
});
Response result = await ctx.WaterfallAsync(
"request/dispatch",
terminal: () => DispatchCoreAsync(request),
request);
listener 按注册顺序成为从外到内的包装层。任何一层不调用 next(),后续 listener 与 terminal 都不会运行;
这不是 bail predicate,而是 continuation 所有权。
C# 专有:contained 通知
public void EventService.EmitContained(string name, params object?[] args);
EmitContained 是明确的 fire-and-forget 边界:按顺序启动 listener,但不等待异步完成;每个失败被捕获后,
若存在 internal/error listener 就以 (Exception error, string source) 报告,否则写入 trace。
internal/error listener 自身失败只写 trace,防止递归错误风暴。
它用于同步 Context.Fork()、已 ready 后的后台 service start、HMR 错误通知等不允许反向打断调用方的场景。
普通 EmitAsync / ParallelAsync / SerialAsync / BailAsync / WaterfallAsync 不会隐式 containment。
派发快照与并发
每次派发先取得同名 hook 快照:
- 派发期间新增的 listener 从下一次派发开始可见;
- 普通 listener 在快照后被 dispose,仍可能在本轮被调用;
Once以原子认领保证至多调用一次:赢得认领的 dispatch 先把它从总线移除再调用;输掉认领的 dispatch 视同该 listener 不存在(emit / parallel 跳过、serial / bail 不计命中、waterfall 进入下一个 listener 或 terminal),也不会为它产生 trace 记录;- 注册、摘除和取快照在锁内完成,listener 本身在锁外运行。
这些规则让 listener 可以安全地注册、摘除或递归派发,而不会在运行用户代码时持有事件总线锁。