This repository has been archived on 2026-08-12. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Cyrene/docs/debug/2026-05-21-crash-cancel.md
T

9.8 KiB
Raw Blame History

紧急诊断报告:页面崩溃 "Cannot read properties of undefined (reading 'cancel')"

日期: 2026-05-21
严重级别: 🔴 Critical — 页面持续崩溃,ErrorBoundary 捕获持久性错误
诊断方法: 源代码静态分析 + CDP (Chrome DevTools Protocol) 运行时分析


1. 错误信息

⚠️ 应用遇到错误: Cannot read properties of undefined (reading 'cancel')

用户报告:

  • 初次触发: 前端在初次对话回复到一半时页面崩溃
  • 后续表现: 刷新页面一直弹出 ErrorBoundary 错误页 + 重试按钮
  • 特点: 持久性错误 — 错误发生在页面初始化阶段

2. 根本原因分析

2.1 .cancel() 引用全局搜索

对整个 frontend/web/src/ 目录进行 .ts / .tsx 文件搜索,所有 .cancel() 调用仅存在于一个文件:

文件 行号 代码
useSpeechSynthesis.ts L205 window.speechSynthesis.cancel(); — speak() 回调内(有 isSupported 守卫)
useSpeechSynthesis.ts L224 window.speechSynthesis.cancel(); — stop() 回调内(无守卫)
useSpeechSynthesis.ts L256 window.speechSynthesis.cancel(); — 组件卸载 cleanup 内(无守卫)

项目不使用 Axios(无 CancelToken),也不使用 AbortController。 HTTP 客户端是原生 fetch(见 client.ts)。

2.2 CDP 运行时确认

通过 Chromium CDP (端口 9225) 在 about:blank 页面执行以下诊断:

// CDP Runtime.evaluate 结果:
'speechSynthesis' in window  → true
typeof window.speechSynthesis → "object"
window.speechSynthesis === undefined → false
window.speechSynthesis === null → false
!!window.speechSynthesis → true
typeof window.speechSynthesis?.cancel → "function"

同时确认了错误消息的精确匹配:

// 直接测试:
var x = undefined; x.cancel();
// → TypeError: "Cannot read properties of undefined (reading 'cancel')"

结论: 错误消息 "Cannot read properties of undefined (reading 'cancel')" 确实是访问 undefined.cancel 导致的 TypeError。在当前 headless Chrome 中,window.speechSynthesis 存在且有 .cancel 方法。但错误发生在特定运行时条件下。

2.3 调用链路分析

App.tsx (登录后渲染)
  └── ChatContainer.tsx
       └── MessageList.tsx
            └── MessageBubble.tsx (每个 assistant 消息)
                 └── AIMessageActions(content)  ← 调用 useSpeechSynthesis()
                      └── useSpeechSynthesis()
                           ├── isSupported = 'speechSynthesis' in window  // ✓ 有守卫
                           ├── speak() → window.speechSynthesis.cancel()  // ✓ isSupported 守卫
                           ├── stop()  → window.speechSynthesis.cancel()  // ✗ 无守卫!
                           └── cleanup → window.speechSynthesis.cancel()  // ✗ 无守卫!

关键代码对比(useSpeechSynthesis.ts):

// ✓ speak() — 有 isSupported 守卫
const speak = useCallback((text, options) => {
    if (!isSupported || !text.trim()) return;  // <-- GUARD
    window.speechSynthesis.cancel();           // 安全
    // ...
}, [isSupported, speakNextChunk]);

// ✗ stop() — 无守卫
const stop = useCallback(() => {
    window.speechSynthesis.cancel();           // 未检查 isSupported!
    setIsSpeaking(false);
    // ...
}, []);

// ✗ cleanup — 无守卫
useEffect(() => {
    return () => {
        window.speechSynthesis.cancel();       // 未检查 isSupported!
        // ...
    };
}, []);

2.4 为什么是「持久性」错误

错误在以下场景触发:

  1. 初次对话崩溃: 用户发送消息 → WebSocket 流式返回 → MessageBubble 渲染 assistant 消息 → AIMessageActions 挂载 → useSpeechSynthesis() 初始化 → 组件因某些原因卸载(React 重渲染、StrictMode 双重挂载、或流式更新导致的条件渲染变化) → cleanup 执行 window.speechSynthesis.cancel() → 如果此时 speechSynthesis 对象不可用 → TypeError → ErrorBoundary 捕获

  2. 刷新后持续崩溃: 用户已登录 → initSession() 从服务端加载历史消息 → 历史消息中包含 assistant 消息 → MessageBubble 渲染 → AIMessageActions 挂载 → 同样的初始化路径触发同样的错误 → ErrorBoundary 持续捕获 → 页面永远无法恢复

  3. 为什么 window.speechSynthesis 可能为 undefined:

    • 尽管 CDP 测试显示它存在,但在某些特定的浏览器/环境组合下(如特定版本的 headless Chrome、无音频设备的服务器环境、或浏览器安全策略限制),window.speechSynthesis 可能是一个不完整的对象,或者在某些时序下(如页面刚加载、onvoiceschanged 未触发时)表现出意外行为
    • React 18 的 StrictMode(在 main.tsx 第10行启用)会双重挂载/卸载组件,增加了 cleanup 被调用的次数和时序复杂度

2.5 辅助触发因素

AIMessageActions 内部还有一个额外的无守卫调用:

const checkEnd = setInterval(() => {
    if (!window.speechSynthesis.speaking) {  // 直接访问 speechSynthesis
        setIsThisSpeaking(false);
        clearInterval(checkEnd);
    }
}, 200);

此 setInterval 没有清理机制 — 如果 AIMessageActions 在 checkEnd 回调执行时已卸载,setIsThisSpeaking 会触发 React 的 "setState on unmounted component" 警告。虽然这不是 .cancel 错误的直接原因,但同样是缺少防御性编程的症状。


3. Zustand Persist 检查

Store 文件 使用 persist?
authStore authStore.ts ❌ 纯 create()
chatStore chatStore.ts ❌ 纯 create()
sessionStore sessionStore.ts ❌ 纯 create()
notificationStore notificationStore.ts ❌ 纯 create()
personaStore personaStore.ts ❌ 纯 create()

所有 Zustand Store 均未使用 persist 中间件,因此不存在序列化到 localStorage 导致 CancelToken/AbortController 实例丢失方法的问题。排除此假设。


4. 七种可能来源的排查

# 假说 验证结果
1 Axios CancelToken 序列化到 localStorage 后丢失 .cancel 方法 ❌ 项目不使用 Axios
2 AbortController 被存入 Zustand store 序列化后丢失 ❌ 项目不使用 AbortController
3 window.speechSynthesis.cancel() 在 speechSynthesis 不支持时调用 ✅ 唯一 .cancel 来源
4 第三方库中有 .cancel 调用 ❌ 依赖仅 react + zustand
5 Service Worker 中有 .cancel 调用 ❌ SW 代码中无 .cancel
6 Zustand persist 中间件序列化问题 ❌ 所有 store 不使用 persist
7 React StrictMode 双重卸载触发 cleanup 时序问题 ⚠️ 可能是加剧因素

5. 修复建议

5.1 立即修复(推荐)

在 useSpeechSynthesis.ts 中添加防御性守卫:

stop() 函数 (L223):

const stop = useCallback(() => {
    if (isSupported) {
        window.speechSynthesis.cancel();
    }
    setIsSpeaking(false);
    setIsPaused(false);
    utteranceRef.current = null;
    chunksRef.current = [];
    chunkIndexRef.current = 0;
}, [isSupported]);  // ← 添加 isSupported 到依赖数组

cleanup effect (L254-261):

useEffect(() => {
    return () => {
        if (isSupported) {
            window.speechSynthesis.cancel();
        }
        if (resumeIntervalRef.current) {
            clearInterval(resumeIntervalRef.current);
        }
    };
}, [isSupported]);  // ← 添加 isSupported 到依赖数组

5.2 防御性增强(推荐)

在 MessageBubble.tsx 的 AIMessageActions 组件中,为 checkEnd 的 setInterval 添加清理:

useEffect(() => {
    return () => {
        if (checkEndRef.current) {
            clearInterval(checkEndRef.current);
        }
    };
}, []);

5.3 架构层建议

考虑将 useSpeechSynthesis() 调用提升到应用层级(如 App.tsx 或 AppLayout.tsx),而非在每个 AIMessageActions 中各自创建一个 hook 实例。这可以避免消息列表渲染时大量重复初始化 SpeechSynthesis 上下文。


6. 诊断工具与数据

  • CDP 测试脚本: debug/cache/test_cdp_cancel_error.py
  • Chromium 调试端口: localhost:9225
  • 前端预览端口: localhost:5199

7. 结论

根因: useSpeechSynthesis hook 中的 stop() 回调和组件卸载 cleanup effect 直接调用 window.speechSynthesis.cancel() 没有任何防御性守卫。当 window.speechSynthesis 在特定运行时条件下不可用(值为 undefined)时,抛出 TypeError: Cannot read properties of undefined (reading 'cancel')。

持久性原因: 已登录用户在刷新页面后,initSession() 加载历史消息,渲染 assistant 消息气泡时触发 AIMessageActions → useSpeechSynthesis(),在 React StrictMode 双重挂载/卸载周期中触发未守卫的 cleanup,导致错误在每次页面加载时必现。

修复优先级: 🔴 P0 — 阻塞所有已登录用户使用应用。