Repository navigation
Replies: 1 comment 1 reply
|
All three findings reproduce in the TypeScript sources behind those bundles, and the second one has a cause that is worth naming precisely — because the null you are crashing on is not "a turn that ended without a reason". It is the signature of a finalization that failed. Source locations. Your
Where the let turnEnds: TurnEndReason | null = null // :312
…
this.session.append('turn/end', { turn, reason: turnEnds! }) // :387The append sits in a So the two facts that make this a visible defect rather than a contained one:
This writer-side defect has four earlier reports. Discussions #8832, #9156, #9646 and #9699 all end at this same line, and their symptom is a blank session list rather than a blank transcript. Yours is the client-side reader of the same null — which is useful evidence for the report: the same missing-reason write reaches at least two independent consumers, and neither is written to survive it. What already exists in this direction. We published On the three fixes themselves:
If you can share what was cancelling in the three sessions that carry the seven null rows — a Stop, a parent cancel, or a plugin's own cancellation — that would name which cause escapes the union and confirm the producer above from the other end. |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
【BUG】客户端事件订阅器因 null 属性读取而死亡,导致转录区永久空白
环境
@deepseek-ai/dsh-desktop 0.2.0-rc.2
Electron 44.0.0
Windows / x64
症状
侧边栏会话列表还在,但对话内容区永久空白
输入框可以打字,发送后界面无任何显示
重启客户端能短暂恢复;只要切换一次会话,立刻再次空白
控制台错误数随操作累积增长(3 → 15 → 24 → 52 → 64)
关键特征:故障跟随「切换会话」这个动作,而不是绑定某个特定会话。 这一点排除了「某个会话数据损坏」,指向共享的渲染通道。
根因
崩溃发生在客户端事件订阅器内部。该订阅器一旦抛出异常就会死亡,导致转录区停止更新并永久空白。
由于订阅器是按面板独立创建的,所以表现为:
崩溃前已渲染的内容保留(重启/重组后短暂正常)
崩溃后该面板永久空白
切换会话会重建订阅器并重放事件,再次踩中同一缺陷
核心错误:
代码块
[session-controller] event feed subscriber failed:
TypeError: Cannot read properties of null (reading 'kind')
三处缺陷
缺陷 1 — 收件箱事件拼接链未做空值防护
文件:dsh-client-ui-chat/lib/client.js 函数:materializePending() / applySplice()
js
function materializePending(state) {
const splices = [];
let current = state;
while (current.kind === "splice") { // ← 读取 null 的属性
splices.push(current);
current = current.previous; // ← 链尾是 null
}
const pending = [...current.ids]; // ← 再次读取 null
// ...
}
function applySplice(previous, splice, seq) {
const priorPending = previous?.state.pending ?? EMPTY_PENDING;
// ↑ 只护住了 previous,
// 没有护住 previous.state
}
分析:
splice 链通过 .previous 向前遍历,链头是 null。循环条件在判断类型前没有检查 current 是否存在。
另一处,可选链 previous?.state.pending 只对 previous 生效;当 previous 是「存在但 state 为 undefined」的对象时,.state.pending 依然会抛错。
缺陷 2 — turn/end 事件的 reason 可能为 null
文件:dsh-client-ui-chat/lib/client.js 函数:failureFrom() 等多处(共 11 处 reason 读取)
js
function failureFrom(match) {
if (match.event.type !== "turn/end") return void 0;
const reason = match.event.data.reason;
const failure = reason.kind === "error" ? reason.error : /* ... */;
// ↑ reason 可能为 null
}
缺陷 3 — trajectory 包的同源缺陷
文件:dsh-client-ui-trajectory/lib/client.js 函数:trajectoryTurnEndDefinition.start()
js
start: (_context, match) => {
if (match.event.type !== "turn/end") throw new Error(/* ... */);
const reason = match.event.data.reason;
const failure = reason.kind === "error" ? displayFailure(reason.error) : void 0;
// ↑ 同样未检查 null
// ...
}
注意: 报错栈显示 client.js:998,但该行号实际属于 trajectory 包——chat 包当时恰好也有相近行号,极易误判。多个 bundle 的文件名都是 client.js,行号不能作为唯一定位依据,需结合「该行附近是否有相关函数定义」交叉验证。
触发数据(来自真实会话日志)
全部会话共 13,441 条事件,其中 turn/end 的 reason 为 null 的有 7 条,分布在 3 个不同会话:
json
{"type":"turn/end","seq":1827,"data":{"turn":N,"reason":null}}
{"type":"turn/end","seq":2011,"data":{"turn":N,"reason":null}}
{"type":"turn/end","seq":2166,"data":{"turn":N,"reason":null}}
{"type":"turn/end","seq":1363,"data":{"turn":N,"reason":null}}
{"type":"turn/end","seq":9670,"data":{"turn":N,"reason":null}}
{"type":"turn/end","seq":9743,"data":{"turn":N,"reason":null}}
另有一条 agent/inbox/spliced 事件满足 removedCount > 0 且无 outcome 字段:
json
{"type":"agent/inbox/spliced","seq":5,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
建议修法(最小改动,不改变正常流程行为)
修复 1 — chat 包补全空值防护
js
// a) 遍历链时检查空值
while (current != null && current.kind === "splice") {
// b) 空链时返回空数组
if (current == null) return [];
// c) 补全整条可选链
const priorPending = previous?.state?.pending ?? EMPTY_PENDING;
修复 2 — chat 包 11 处 reason 读取加可选链
js
event.data.reason?.kind
修复 3 — 在唯一入口统一归一化(conversation 包)
比起在 9 个调用点分别加防护,在 ConversationNodeAssembler.dispatchInput() 入口处理一次更稳妥:
js
dispatchInput(input, accept) {
input = normalizeTurnEndReason(input); // ← 注意:必须替换 input 本身
const event = input.event;
// ...
}
function normalizeTurnEndReason(input) {
⚠️ 这里的坑: 最初的实现写成 const event = normalizeTurnEndReason(input.event); ——只修复了局部变量,而下游 acceptMatch() 使用的是 input 本身,导致 start() 仍收到未修复的事件。 修补「统一入口」时必须确认下游真正使用的是哪个对象。
const event = input !== null && typeof input === "object" ? input.event : void 0;
if (event === null || typeof event !== "object") return input;
if (event.type !== "turn/end") return input;
const data = event.data;
if (data === null || typeof data !== "object") return input;
if (data.reason !== null && data.reason !== void 0) return input;
return { ...input, event: { ...event, data: { ...data, reason: { kind: "unknown" } } } };
}
修复 4 — 两处 reason 赋值加默认值
js
const reason = match.event.data.reason ?? { kind: "unknown" };
补充说明
我确认过 Discussions 中已有的 321 个报告。 「Web UI 与前端」这一类里没有这一条:
#317 / #370 是长思考导致页面卡死(Maximum call stack size exceeded)
#682 / #1316 是工具调用循环拖垮浏览器
症状与根因均不同。本条的特殊之处是:症状为「静默空白」,不打开 DevTools 就找不到入口。
本地已按上述方案修补 app.asar(仅 3 个文件,0 新增 0 删除,全部 11,470 个内联文件保持一致),并通过 5 层验证:
结构完整性(逐文件 SHA256 比对)
行为测试(materializePending(null/undefined)、null 结尾的 splice 链等)
用 7 个真实记录的 null-reason 事件直接调用修复后的 start() 闭包 → 0 抛错
14,341 条真实事件全量回放 → 0 异常
全量客户端 bundle 编译测试
一个建议(比补丁本身更重要)
即使不采纳上述补丁,也希望考虑给事件订阅器加一层保护。
目前任何一个订阅器抛异常,都会造成「整个面板永久空白、且界面不给任何提示」——这个失败模式太隐蔽了。 一个 null 字段就能让用户以为「对话全部丢失」。
至少应该:
订阅器外层加 try/catch,捕获后降级并给出可见提示,而不是静默死亡
或在开发模式下把订阅器异常打到界面上
本报告由用户与其 DSH 内的 AI 助手协作完成。修复补丁与全部验证脚本保留在本地,可按需提供。
All reactions