← 回到 infra 主页

DeepSeek Harness · Upstream · Issue #11 草案 · 自包含

每个 session/follow 都注册一个 { global: true } 监听器:global 绕过作用域过滤,于是每个被派发的事件都要先选中全部跟随流

本页自包含——不需要读我们此前任何一份报告。DSH 的 Host 是一个 Node 进程,服务全部 Session;浏览器跟随某个 Session 时,Host 会开一条 session/follow 流,并注册带 { global: true } 选项的监听器。这里的 global 有确切含义:它绕过 Cordis 的作用域过滤——进程内任何会话的事件都会先把它选出来,之后回调体里才用 if (session.id !== target) return; 把事件丢掉。所以「选取监听器」这笔成本按每个被派发的事件 × 开启中的跟随流数计,而且由每个会话的事件来付;进程总量约 O(会话数 × 跟随流数)。本页用的不是对那几行的复刻模型,而是随包真实的 Cordis Context 与 events.dispatch(),调用形态与 dsh-session 每次 append 时完全一致。实测:每监听器每事件约 1.62 µs,线性拟合 r² = 1.0000,64 条跟随流下单次事件仅选取就要约 105 µs;而且 selected_per_emit 恰好等于 N,这正是「作用域过滤被绕过」的直接证据。

一句话判据(30 秒复现):node ops/dsh/upstream-issue-follow-listener-fanout/follow-fanout-bench.mjs --n 0,1,2,4,8,16,32,64 → 末行 FOLLOW_FANOUT_BENCH … ns_per_listener=1621.24 r2=1.0000 result=LINEAR(退出码 0)。判据是两件事同时成立:① ns_per_emit 随 N 线性增长;② selected_per_emit 恒等于 N——后者说明这些监听器没有经过作用域过滤。
优先级:中。①它是一条随「用户开几个会话」增长的成本,而 Host 是单主线程的,超线性项在这条线程上代价尤其高;②量级不算致命(单事件几十微秒级),但它是纯选取开销——还没进任何回调体、没做任何事就已经付掉;③修法干净且局部:把订阅按 session id 建索引,或把 id 判断提到派发谓词里。

一句话结论与两条事实

两条都是本次实测(2026-09-14),命令与原始输出都在本页。

① 每事件成本随跟随流数线性增长,且作用域过滤被绕过

用随包真实的 Cordis 派发同一形态的 session/event:N=0 时每次派发 1,135.9 ns,N=64 时 104,921.9 ns。线性拟合斜率 1,621 ns / 监听器 / 事件,r² = 1.0000。决定性的第二列:selected_per_emit 在每一档都恰好等于 N(1/2/4/8/16/32/64)——如果这些 global 监听器经过作用域过滤,被选中的数量应该只与「真正关心这个会话的流数」相关,而不是等于全部。

② 代价付在每个事件上,而不只是被跟随的那个会话

注册点是 dsh-api-session-controller:每个 follow 流注册 3 个 global 监听器(session/event、session/created 无条件;agent/assistant-stream 在 request.assistantStream === true 时)。而 global 的语义就是「不按作用域筛」,所以进程内任何会话派发事件时,这 3×F 个回调都会被选中一遍,再各自在回调体里按 id 丢掉。总量因此约 O(会话数 × 跟随流数)。

实测锚点(含原始输出)

环境:DSH 0.1.5-rc.1(离线包)、cordis 4.0.2、Node v24.16.0。脚本从随包安装目录导入真实的 Context,不依赖网络、不启动 DSH、无第三方依赖。

# cordis loaded from: [app-dir]/app-[version]/node_modules/@deepseek-ai/cordis/lib/index.js
# iters per level: 60000
N	n_per_emit	ns_per_emit	ns_per_listener	selected_per_emit
0	0	1135.9	n/a	0.00
1	1	2892.6	2892.6	1.00
2	2	4393.2	2196.6	2.00
4	4	7632.1	1908.0	4.00
8	8	14236.6	1779.6	8.00
16	16	27438.4	1714.9	16.00
32	32	53143.4	1660.7	32.00
64	64	104921.9	1639.4	64.00
#
FOLLOW_FANOUT_BENCH base_ns_per_emit=1135.9 ns_per_listener=1621.24 r2=1.0000 max_n=64 max_ns_per_emit=104921.9 result=LINEAR

读法:第一列 N = 同时开启的跟随流数;ns_per_emit = 一次事件派发(含把全部监听器选出来、为每个存活监听器 bind 一个闭包、再逐个调用)的纳秒数;selected_per_emit = 每次派发实际被选中的回调数。第三列恒等于第一列,是本页的核心证据。

口径说明。基准 N=0 的 1,135.9 ns 是派发机制本身的开销(含 internal/dispatch 广播与作用域判定)。斜率 1,621 ns 才是每条跟随流带来的边际成本。绝对纳秒数随机器变化,线性关系与 selected_per_emit == N 才是可移植的发现。

代码级证据(指到行)

行号按随包发布的 lib/(0.1.5-rc.1)核对,逐条回源引用。

#事实锚点(逐字)
①每个 follow 流注册一个 session/event 的 global 监听器;先被选中,再在回调体里按 id 丢掉dsh-api-session-controller/lib/index.js:1419 this.ctx.on("session/event", (session, event) => {;:1420 if (session.id !== target) return;;:1426 }, { global: true });
②同一处还有第二条无条件的 global 注册(session/created),以及条件性的第三条:1427 const disposeCreated = this.ctx.on("session/created", (session) => { → :1435 }, { global: true });;:1436 const disposeAssistantStream = request.assistantStream !== true ? void 0 : this.ctx.on("agent/assistant-stream", ({ agent, frame }) => { → :1444 }, { global: true });
③append 路径对每一个事件都解析一次完整监听器集合,然后逐个调用dsh-session/lib/index.js:963 function collectSessionCallbacks(ctx, args) { return [...ctx.events.dispatch("emit", args)]; };:1195 callbacks = collectSessionCallbacks(entry.emitCtx, [entry.carrier, "session/event", ...callbackArgs]);;:1202 invokeContainedSessionObservers(...)
④global 短路了作用域谓词,且同一表达式每次派发都新建一个数组 + 每个存活监听器一个绑定闭包cordis/lib/index.js:263 return (this._hooks[name] || []).filter((hook) => hook.global || !filter || filter.call(thisArg, hook.ctx)).map((hook) => hook.callback.bind(thisArg));;:261 每次派发还会 this.emit("internal/dispatch", type, name, args, thisArg)

为什么 global 是问题所在

这是本 issue 的机制核心,单独说清楚——它也解释了为什么 selected_per_emit 恰好等于 N。

看 ④ 号锚点那个谓词:hook.global || !filter || filter.call(thisArg, hook.ctx)。对 { global: true } 注册的 hook,第一项就短路为真——后面的作用域过滤(filter.call(...))根本不会被求值。这就是 global 的实现含义:不参与作用域筛。

于是 follow 流的写法变成:先用「全局」的代价把回调选出来,再在回调体第一行用 id 判断把它丢掉。过滤没有消失,只是从派发期挪到了回调期——而派发期每事件都要为全部 F 个流各做一次「选中 + bind 闭包」,无论这些流是否关心这个会话。

对比一下:如果这个 id 判断能进到派发谓词里(filter 回调),不匹配的流既不会被选中、也不会被 bind,成本就从 O(F) 降到 O(匹配数)。这是修法 1 与 2 的由来。

还要注意 ② 号锚点:每个 follow 流实际注册的是 3 个 global 监听器,不是 1 个。其中 session/event 与 session/created 是无条件注册的,所以「开一条跟随流」这件事本身就固定增加 2 个全局 hook;agent/assistant-stream 那条仅在 request.assistantStream === true 时注册。

实测 / 推断 / 未主张

按本仓约定把三者分开,避免把结构推断当实测用。

类别内容
已实测上面的纳秒表,跑在随包真实 Cordis 上;selected_per_emit == N 证明作用域过滤被绕过;线性拟合斜率 1621 ns 与 r² = 1.0000。
已实测本条列出的全部 file:line 与逐字引文(四个锚点各自回源核对过)。
推断「整进程 ≈ O(会话数 × 跟随流数)」。它由「每事件成本付给进程内每一个被派发的事件」推出,但我没有在生产 Host 上插桩统计「每事件时的并发跟随流数」,所以这是结构推断,不是端到端实测。
未主张我没有测这个项造成的终端用户延迟。它是同一条主线程上若干成本之一,本页只隔离并量化它本身,不主张它是主导项。
未主张不涉及浏览器侧渲染成本;不对其它硬件上的绝对纳秒数作承诺。

复现:零依赖脚本与判据行

脚本从随包安装目录导入真实的 Context;不联网、不启动 DSH、不写任何文件。

# 0) 自检(纯函数:线性拟合与参数解析)
node ops/dsh/upstream-issue-follow-listener-fanout/follow-fanout-bench.mjs --selftest
#    期望:FOLLOW_FANOUT_BENCH_SELFTEST_PASS checks=5

# 1) 主实验:N = 开启中的跟随流数
node ops/dsh/upstream-issue-follow-listener-fanout/follow-fanout-bench.mjs --n 0,1,2,4,8,16,32,64

# 2) 加大统计量(默认每档 60000 次派发)
node ops/dsh/upstream-issue-follow-listener-fanout/follow-fanout-bench.mjs --n 0,8,64 --iters 300000

# 判据行(末行):
#   FOLLOW_FANOUT_BENCH base_ns_per_emit=… ns_per_listener=… r2=… result=LINEAR
# 判据成立需同时满足:ns_per_emit 随 N 线性增长,且 selected_per_emit 恒等于 N。
# 退出码:0 = 复现;1 = 未复现;2 = 导入/用法失败。

建议修法(分级)

从「消除这一项」到「让它可见」。

级别修法为什么 / 怎么做
P0订阅按 session id 建索引让事件只到达「要这个会话」的流,而不是先全部选中再丢掉。这是消除乘性项,而不是缩小常数。
P0把 id 判断提到派发谓词里若确实必须用 global 注册,就把 id 判断放进 filter 回调(④ 号锚点里被 hook.global || 短路掉的那一项),让不匹配的监听器既不被选中也不被 bind。成本从 O(F) 降到 O(匹配数)。
P1按 (事件名, 作用域) 缓存监听器集合④ 号锚点每次派发都 filter(...).map(hook => hook.callback.bind(...))——新建数组 + 每监听器一个闭包。在 hook 集合不变时可复用。
P2暴露派发耗时与监听器集合大小的指标这类成本只有在有指标时才看得见;否则它只会表现为「会话开多了就卡」,而没人知道是哪一项。

可提交的 issue #11 正文(英文,自包含)

下面整段可直接粘贴,不依赖我们此前的任何报告。

Title: Every `session/follow` stream registers a { global: true } listener, so each
       dispatched event costs O(open follow streams) process-wide - linear fan-out that
       bypasses scope filtering

Summary
-------
A DSH Host is one Node process that serves every Session. When a browser follows a Session, the
host opens a `session/follow` stream and registers listeners with the option `{ global: true }`.
"Global" means the listener BYPASSES Cordis's per-context scope filter: it is selected for EVERY
event dispatched anywhere in the process, and only then does its own body discard the event with
`if (session.id !== target) return;`.

The cost of selecting listeners is therefore paid per dispatched event, in proportion to the number
of open follow streams - and every Session's events pay it, not just the followed one. Across a
process the total is roughly O(sessions x follow streams).

I measured the real primitive, not a model of it: the benchmark below drives the shipped Cordis
`Context` / `events.dispatch()` through the same call shape `dsh-session` uses on every append.

Why this matters
----------------
A single Host already runs every Session and every in-process subagent on one JavaScript main
thread. This adds a per-event cost that grows with how many sessions the user has open, and it is
paid by every event in the process - including events of sessions nobody is watching. At 64 open
follow streams a single dispatched event costs about 105 microseconds of pure listener selection,
before any listener does any work.

Environment
-----------
  DSH        0.1.5-rc.1 (offline bundle, @deepseek-ai/*)
  Cordis     4.0.2            (the event bus that dispatches session events)
  Node       v24.16.0
  Command    node ops/dsh/upstream-issue-follow-listener-fanout/follow-fanout-bench.mjs

Evidence (measured)
-------------------
  N	ns_per_emit	selected_per_emit
  0	1135.9	0.00
  1	2892.6	1.00
  2	4393.2	2.00
  4	7632.1	4.00
  8	14236.6	8.00
  16	27438.4	16.00
  32	53143.4	32.00
  64	104921.9	64.00

  Linear fit over N: slope = 1621 ns per listener per emit, r2 = 1.0000.

Two things this table establishes by itself:

  * `selected_per_emit` equals N exactly. If global listeners were scope-filtered, the number
    selected would stay near the number of listeners that actually care. It does not: every global
    listener is selected for every event, and the filtering happens afterwards, inside the callback.
  * The marginal cost is ~1.6 microseconds per listener per event, and it is additive: at 64 open
    follow streams one dispatched event costs ~105 microseconds regardless of which Session it
    belongs to.

Code references (0.1.5-rc.1, shipped lib/)
-----------------------------------------
  dsh-api-session-controller/lib/index.js:1419  this.ctx.on("session/event", (session, event) => {
  dsh-api-session-controller/lib/index.js:1420      if (session.id !== target) return;
  dsh-api-session-controller/lib/index.js:1426  }, { global: true });
  dsh-api-session-controller/lib/index.js:1427  const disposeCreated = this.ctx.on("session/created", ...
  dsh-api-session-controller/lib/index.js:1435  }, { global: true });
  dsh-api-session-controller/lib/index.js:1436  ... this.ctx.on("agent/assistant-stream", ({ agent, frame }) => {
  dsh-api-session-controller/lib/index.js:1444  }, { global: true });

    -> three per-follow registrations, two of them unconditional (:1427/:1435 is registered for
       every follow stream; :1436 is conditional on request.assistantStream === true).

  dsh-session/lib/index.js:963   function collectSessionCallbacks(ctx, args) { return [...ctx.events.dispatch("emit", args)]; }
  dsh-session/lib/index.js:1195  callbacks = collectSessionCallbacks(entry.emitCtx, [entry.carrier, "session/event", ...callbackArgs]);
  dsh-session/lib/index.js:1202  if (callbacks !== void 0 && entry !== void 0) invokeContainedSessionObservers(...)

    -> the append path resolves the FULL listener set for every single event, then invokes it.

  cordis/lib/index.js:261  if (!name.startsWith("internal/")) this.emit("internal/dispatch", type, name, args, thisArg);
  cordis/lib/index.js:263  return (this._hooks[name] || []).filter((hook) => hook.global || !filter || filter.call(thisArg, hook.ctx)).map((hook) => hook.callback.bind(thisArg));

    -> `hook.global ||` short-circuits the scope predicate, and the same expression allocates a
       filtered array plus one bound closure per surviving listener, per dispatch.

What is measured vs inferred
----------------------------
  MEASURED   the nanosecond table above, on the shipped Cordis, with `selected_per_emit == N`
             showing the scope filter is bypassed; the linear fit slope and r2.
  MEASURED   the code paths quoted above, at those line numbers.
  INFERRED   the "O(sessions x follow streams)" characterisation of a whole process. It follows from
             the measured per-event cost being paid for every event dispatched in the process, but I
             did not instrument a production Host to count concurrent follow streams per event.
  NOT CLAIMED  I have not measured end-user latency caused by this term. It is one contributor among
             several on the same thread; this report isolates and quantifies the term itself.

Not claimed
-----------
  * This is not claimed to be the only or the dominant cost on the host's main thread.
  * No claim is made about browser-side rendering cost.
  * The absolute nanoseconds will differ on other hardware; the LINEARITY and the
    `selected_per_emit == N` behaviour are the portable findings.

Suggested fixes
---------------
  1. Key the subscription by session id instead of registering a per-follow global listener, so an
     event only reaches the streams that asked for that session. This removes the multiplicative
     term rather than shrinking it.
  2. If a global registration is unavoidable, hoist the id test into the dispatch predicate (the
     `filter` callback) rather than the listener body, so non-matching listeners are never selected
     and never bound.
  3. Consider resolving and caching the listener set per (name, scope) instead of rebuilding a
     filtered array plus bound closures on every dispatch.
  4. Expose a metric for dispatch time and listener-set size so this stays visible in production.

Reproduction
------------
  node ops/dsh/upstream-issue-follow-listener-fanout/follow-fanout-bench.mjs --selftest
  node ops/dsh/upstream-issue-follow-listener-fanout/follow-fanout-bench.mjs --n 0,1,2,4,8,16,32,64
  # last line: FOLLOW_FANOUT_BENCH ... result=LINEAR   (exit 0)
  # It imports the shipped Cordis Context from the installed bundle; no network, no DSH process,
  # no third-party dependency.

Related (not required to read): this is one term on a host that runs every session on a single
JavaScript main thread; I have reported that structure separately.

最后修改:2026-10-01 19:51:07 +08:00