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# 逐项对照

TypeScriptC#状态与差异
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.prependbool 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失败处停止并直接传播。
WaterfallAsynclistener 嵌套在 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 可以安全地注册、摘除或递归派发,而不会在运行用户代码时持有事件总线锁。

在 GitHub 上编辑此页