Skip to content

[Bug] Renderer crashes with React error #31 on session reload when transcript contains malformed AskUserQuestion #1400

Closed

Description

@kizemo

Claude Code Haha — Renderer Crash + Sidecar BOM Bug Report

  • Date: 2026-09-29 (CST)
  • Reporter: kizemo (via MiniMax Code diagnostic session)
  • App version: claude-code-desktop/0.6.7, Electron/42.7.0
  • Sidecar binary: claude-sidecar-x86_64-pc-windows-msvc.exe (Tauri + Bun, ~115 MB)
  • Affected repo: github.com/NanmiCoder/cc-haha

TL;DR

cc-haha shows a blank "出现异常。错误已记录到诊断日志。" error page on every launch when the user has at least one session whose transcript contains a malformed AskUserQuestion tool_use whose options[*].description is an object instead of a string. The root cause is a missing defensive render in the renderer that propagates ErrorBoundary to the whole tree; a side-channel bug (scheduled_tasks.json corrupted to NUL/BOM) is unrelated to the user-visible crash but produces noise in logs.

Two distinct bugs are reported in this document:

  1. Renderer crash (primary, user-visible) — React error 调用工具有问题? #31, {path, $text} hast object rendered as a child.
  2. Sidecar JSON parsing (secondary, log noise) — CronScheduler fails to read scheduled_tasks.json when the file is empty / NUL-filled / BOM-prefixed.

1. Environment

Item Value
OS Windows 10/11 (NTFS)
Install dir D:\Program Files\Claude Code Haha\
User data (active) E:\Users\Duanyi\.claude\ (mirrored to C:\Users\Duanyi\.claude\)
USERPROFILE C:\Users\Duanyi
CLAUDE_CONFIG_DIR not set (sidecar falls back to os.homedir() + "/.claude")
LLM provider (per cc-haha/settings.json) https://api.minimaxi.com/anthropic (third-party proxy, not Anthropic direct)
Models in use MiniMax-M3[1m] / MiniMax-M2.7-highspeed
Last opened session 91f665d5-efe2-4f00-a9f7-314fc8730052 ("扩展json撰写", 458 messages, project F--soft-00selfmade-filemanager)

2. User-Visible Symptoms

  1. App launched; sidecar started; HTTP server bound to 0.0.0.0:64467.
  2. WebSocket connected to session 91f665d5....
  3. UI rendered most of the message history normally, then showed:
    • "出现异常。"
    • "错误已记录到诊断日志。"
    • "重试" button (no effect on click)
    • Doctor card + "重置安全 UI 状态" button (resets UI fields but does not clear transcript)
  4. Renderer process repeatedly restarted: [renderer] [process-gone] reason=killed exitCode=1 → [renderer] [recovery-started] trigger=process-gone:killed → recovery completes → same error again.

3. Diagnostic Logs

3.1 ~/.claude/cc-haha/diagnostics/runtime-errors.log

[2026-09-28T23:49:24.266Z] ERROR client_react_error_boundary
summary: Minified React error #31; visit https://reactjs.org/docs/error-decoder.html
         ?invariant=31&args[]=object with keys {path, $text}
componentStack:
  at p / div / div / button / div / div / div
  at pU (App-DWep8kRD.js:345:388)
  at App-DWep8kRD.js:346:97765
  ...
  at u (ErrorBoundary-BnweEjNj.js:1:292)

(5 occurrences total, all with the same {path, $text} signature; timestamps cluster at 2026-09-28T23:49:24–23:49:42.)

3.2 ~/.claude/cc-haha/diagnostics/electron-host.log

[claude-server] [stderr] [CronScheduler] Error during tick:
  ApiError: Failed to read scheduled tasks:
    JSON Parse error: Unrecognized token '<NUL>'  (or '\uFEFF' after attempted repair)
  at readTasksFile (claude-sidecar.js:11102:18809)
  at async listTasks   (claude-sidecar.js:11102:17406)
  at async tick        (claude-sidecar.js:11231:3122)

[claude-server] [stderr] [CronScheduler] Error cleaning up stale runs:
  SyntaxError: JSON Parse error: Unrecognized token '...'
  at Fe5 (claude-sidecar.js:11230:1203)
  at e_8 (claude-sidecar.js:11230:1325)
  at async cleanupStaleRuns (claude-sidecar.js:11232:3904)

[renderer] [process-gone] reason=killed exitCode=1
[renderer] [recovery-started] trigger=process-gone:killed

3.3 ~/.claude/scheduled_tasks.json (corrupted)

Original (before any repair):

size: 713 bytes
hex: 00 00 00 00 00 00 ... 00 (all NUL)
JSON parse: ✗ Unrecognized token '\u0000'
last write: 2026-09-26 12:30:31

4. Root Cause Analysis

Bug A — Renderer crash on {path, $text} (PRIMARY)

A.1 Trigger

A model output produced a malformed AskUserQuestion tool_use where one option's description field is an object instead of a string. Sidecar's runtime validation rejects the call and emits InputValidationError:

<tool_use_error>InputValidationError: AskUserQuestion failed due to the following issue:
The parameter `questions[0].options[0].description` type is expected as `string`
but provided as `object`</tool_use_error>

A.2 Transcript evidence

Path: ~/.claude/projects/F--soft-00selfmade-filemanager/<session>.jsonl, line 515 (the offending assistant tool_use):

{
  "type": "tool_use",
  "name": "AskUserQuestion",
  "input": {
    "questions": [
      {
        "question": "上述 5 个文件确认删除吗?",
        "header": "删除确认",
        "options": [
          {
            "label": "确认删除全部 5 个",
            "description": {                            ← BUG: should be string
              "path": "`(一次一个,不用 wildcard)。CLAUDE.md 禁止 wildcard 删多个文件。",
              "$text": "我会逐个执行 `rm "
            }
          },
          {
            "label": "先别删,再想想",
            "description": "你会修改选择,或先备份再说。"   ← correct: string
          }
        ],
        "multiSelect": false
      }
    ]
  }
}

The description value is a hast (hypertext AST) node — {path, $text} is the diagnostic React error #31 gives when an mdast/hast object is mistakenly passed as a React child. The model appears to have wrapped part of its prose in an AST literal mid-stream instead of producing a string.

A.3 Renderer behavior

App-DWep8kRD.js:345:388 (pU) and App-DWep8kRD.js:419:4400 (mge) consume tool_use.input.questions[].options[].description and pass it directly to a markdown renderer. The renderer returns a hast tree; when this tree is placed inside JSX as a child, React throws #31: Objects are not valid as a React child (found: object with keys {path, $text}).

The top-level ErrorBoundary (ErrorBoundary-BnweEjNj.js:1:292) catches the throw and replaces the entire UI with the screenshot's "出现异常" page — including the unrelated Doctor card — making recovery without manually clearing transcript impossible from the UI.

A.4 Why it persists across restarts

desktop-ui.json (in ~/.claude/cc-haha/) stores pet.lastSessionId = "91f665d5-...". Every launch re-opens this session and re-renders the same line, triggering the same crash. The "重置安全 UI 状态" button only clears UI-state fields, not transcript content (per the error page's own copy: "聊天历史、模型配置、Skills、MCP、IM 和 OAuth 始终受保护").

A.5 Side note — type=queue-operation

The transcript contains {"type":"queue-operation","operation":"remove","timestamp":"...22:47:32.351Z"} immediately after the error result. This indicates the runtime flagged the tool_use as failed and removed it from the execution queue, but the transcript (and thus the renderer replay) still contains it.


Bug B — Sidecar CronScheduler JSON parse (SECONDARY)

B.1 Trigger

scheduled_tasks.json becomes an invalid JSON file (NUL-filled or BOM-prefixed). Each CronScheduler tick (every 60 s) tries to read it via:

async readTasksFile() {
  try {
    let q = await ud.readFile(this.getTasksFilePath(), "utf-8"),
        K = JSON.parse(q);
    if (!Array.isArray(K.tasks)) return {tasks: []};
    return K
  } catch (q) {
    if (q.code === "ENOENT") return {tasks: []};
    throw B6.internal(`Failed to read scheduled tasks: ${q.message}`);
  }
}

Path resolution:

function getTasksFilePath() {
  let q = process.env.CLAUDE_CONFIG_DIR || $e5.homedir() + "/.claude";
  return $e5.join(q, "scheduled_tasks.json");
}

With CLAUDE_CONFIG_DIR unset and USERPROFILE=C:\Users\Duanyi, this resolves to C:\Users\Duanyi\.claude\scheduled_tasks.json — which is mirrored/identical to the one under E:\Users\Duanyi\.claude\.

B.2 Reproduction

  1. Overwrite scheduled_tasks.json with 713 NUL bytes (any cause — power loss during write, antivirus quarantine, fs corruption, etc.).
  2. Launch app.
  3. Observe CronScheduler log spam every 60 s.

B.3 BOM regression (during this user's attempted repair)

PowerShell 5.1's Set-Content -Encoding UTF8 writes a BOM (EF BB BF) by default. The user repaired the file with PowerShell; Bun's JSON.parse is strict and rejects BOM. The sidecar then logs Unrecognized token '\uFEFF' instead of '\u0000'. A re-write with System.Text.UTF8Encoding(false) (no BOM) clears the symptom. This is a Windows-onboarder footgun; not the user's fault.


5. Evidence Files (paths only, not contents)

~/.claude/cc-haha/diagnostics/runtime-errors.log           (timestamps: 2026-09-28T23:23..23:49)
~/.claude/cc-haha/diagnostics/electron-host.log            (process-gone, sidecar exit/entry)
~/.claude/cc-haha/diagnostics/diagnostics.jsonl            (consolidated structured events)
~/.claude/projects/F--soft-00selfmade-filemanager/<sid>.jsonl   (offending transcript, 521 lines)
~/.claude/scheduled_tasks.json.bak.20260929_073521          (713 NUL bytes, pre-repair snapshot)
~/.claude/scheduled_tasks_log.json.bak.20260929_073521      (20810 bytes, pre-repair snapshot)
~/.claude/projects/.../<sid>.jsonl.bak.20260929_084704      (transcript snapshot before description fix)

6. Suggested Fixes (upstream)

Fix A — Defensive rendering (PRIMARY)

In App-DWep8kRD.js around line 345 (pU) and 419 (mge), before passing option.description to any markdown / hast pipeline:

const desc = option.description;
if (typeof desc !== 'string') {
  // Render label only, or a sanitized placeholder, or the error reason.
  // Never forward a non-string into the markdown pipeline.
  return <p className="opt-desc-broken">{option.label} <em>(description unavailable)</em></p>;
}

Or, more conservatively: skip the option entirely from the rendered UI but keep the label, since the upstream model already received the validation error and won't act on it.

Also: do not let the ErrorBoundary replace the whole tree on a single tool_use render failure. Either scope it to the message bubble, or auto-recover by skipping the offending option in a componentDidCatch handler.

Fix B — Sidecar JSON hardening

  1. Read scheduled_tasks.json with explicit BOM stripping:
    const text = await Bun.file(path).text(); // Bun auto-strips BOM
    // or, in Node-style: text = Buffer.from(buf).toString('utf8').replace(/^\uFEFF/, '');
  2. On JSON.parse failure, treat as {tasks: []} rather than throwing — but log the bad payload to corruption-evidence.json (the existing diagnostics/corruption-evidence.json is already wired for this; just call it on parse error too).
  3. On empty file (0 bytes), do NOT re-throw. Re-write a fresh {"tasks":[]} to disk and return that, instead of looping forever on parse errors.
  4. Document that PowerShell Set-Content -Encoding UTF8 writes a BOM; ship a tiny cli-diagnostics note warning Windows users.

Fix C — Transcript schema validation at write time

When persisting an assistant tool_use into *.jsonl, validate that the shape matches the tool's JSON schema (Zod or hand-written). If a model produces description: {…} for AskUserQuestion, rewrite it to description: "" before persistence, so the transcript never carries a poison record.

Fix D — desktop-ui.json recovery

Add a checkbox to the Doctor card: "Skip last session on next launch" — sets a flag the renderer honors to land the user on the project list instead of auto-opening the crashing session. This unblocks users without requiring manual desktop-ui.json edits.


7. User Workarounds (until upstream fix)

  1. Clear the offending tool_use from transcript: locate the <session>.jsonl line containing "description": { followed by "path" and "$text", replace it with "description": "..." (string) or delete the whole tool_use entry. Restart the app.
  2. Edit ~/.claude/cc-haha/desktop-ui.json: change or remove pet.lastSessionId. Restart the app; it will open the project list instead of the crashing session.
  3. Repair scheduled_tasks.json if it gets corrupted again: write {"tasks":[]} without a BOM. On Windows, use System.IO.File]::WriteAllText($path, $json, (New-Object System.Text.UTF8Encoding $false)) from PowerShell — not Set-Content -Encoding UTF8.

8. Environment Snapshot at Time of Diagnosis

electron host version: claude-code-desktop/0.6.7
chrome: 148.0.7778.280
electron: 42.7.0
node_pty: 1.23.251008001
sidecar stack: B:/~BUN/root/claude-sidecar.js (Bun runtime, source maps present in asar)
sidecar binary: resources/app.asar.unpacked/src-tauri/binaries/claude-sidecar-x86_64-pc-windows-msvc.exe (115,294,208 bytes)
db files:
  index-v1.sqlite       124,239,872 bytes
  search-index-v1.sqlite 188,358,656 bytes
  trace-index-v1.sqlite  136,228,864 bytes

9. Minimal Reproduction for Upstream

  1. Add to a new session, ask the model to use AskUserQuestion with a Chinese-language description that contains backticks and code fragments.
  2. Confirm model emits description: {path: ..., $text: ...} (object) instead of description: "..." (string).
  3. Sidecar returns InputValidationError, model retries.
  4. Renderer renders the failed tool_use from transcript on session reopen → React error 调用工具有问题? #31 → ErrorBoundary → "出现异常".

A unit test could simply feed { description: {path: "x", $text: "y"} } into the pU component and assert no crash.


Prepared for upstream issue tracker. All sensitive content (auth tokens, user-specific paths) redacted.

Activity

  1. kizemo commented on Sep 30, 2026

    @kizemo
    Author

    Additional diagnostic finding (2026-09-30)

    After submitting this issue, I hit a second startup failure on the same cc-haha 0.6.7 install. Different symptom, same family of timing bugs in the sidecar startup path.

    Symptom

    UI shows:

    启动错误
    Error invoking remote method 'desktop:runtime:get-server-url':
      desktop server did not report healthy at http://127.0.0.1:53224/health
      within 30 seconds: healthcheck timed out after 500ms
    

    Server log:

    [claude-sidecar] starting server mode (win32/x64)
    [CronScheduler] Starting — checking every 60 s
    [Server] Claude Code API server running at http://0.0.0.0:53224
    [startup-error] desktop server did not report healthy at http://127.0.0.1:53224/health within 30 seconds: healthcheck timed out after 500ms
    [exit] sidecar exited (code=1, signal=null)
    

    Sidecar binds the port successfully but /health never returns inside the 500ms × 60 = 30s window.

    Investigation

    On the same machine ~/.claude/cc-haha/diagnostics/runtime-errors.log shows:

    [2026-09-30T09:28:54.364Z] WARN server_event_loop_stall
      Server event loop was delayed by 3771.1ms
    [2026-09-30T09:21:17.493Z] WARN client_api_request_slow
      GET /api/scheduled-tasks took 9536.4ms
    [2026-09-30T09:20:45.288Z] WARN client_api_request_slow
      GET /api/sessions/.../turn-checkpoints took 16022.2ms
    [2026-09-30T09:21:17.511Z] WARN client_api_request_slow
      GET /api/teams/.../plan took 9537.4ms
    [2026-09-30T09:21:17.538Z] WARN client_api_request_slow
      GET /api/tasks/lists/... took 9583.6ms
    

    SQLite WAL on this machine:

    index-v1.sqlite-wal       14 MB
    search-index-v1.sqlite-wal 10 MB
    trace-index-v1.sqlite-wal   8 MB
    

    After force-checkpointing all three with PRAGMA wal_checkpoint(TRUNCATE), the WALs dropped to 0 bytes and cc-haha started successfully on the very next launch.

    Root cause

    Sidecar's startup sequence blocks the Bun event loop while:

    1. Checkpointing the multi-MB SQLite WAL into the main DB
    2. Scanning ~/.claude/session-env/ (53 entries on this machine)
    3. Building /api/sessions/.../turn-checkpoints, /api/teams/.../plan, /api/tasks/lists/..., /api/scheduled-tasks

    Total observed stall: 0.6 – 3.7 s on event loop, but individual request handlers take 9 – 16 seconds during warm-up. The /health endpoint, which is supposed to return fast, can't respond inside its 500 ms window because the event loop is parked on this blocking work.

    Suggested fixes (priority order)

    1. Sidecar should answer /health immediately on bind, even with a {status:"warming"} payload, and run heavy init in a worker after listen() returns. This is the single highest-impact change.
    2. cc-haha main process healthcheck: 0.5 s × 60 = 30 s is too aggressive for first-launch init. Recommend either (a) 5 s × 30 = 150 s window, (b) exponential backoff (0.5 s → 5 s over 60 s), or (c) just trust sidecar once it's bound the configured port and skip the /health round-trip.
    3. WAL discipline: prevent multi-MB WAL accumulation across runs. Either auto-checkpoint on graceful shutdown, or cap WAL size with PRAGMA journal_size_limit so older pages get auto-checkpointed.
    4. Bonus: log the port-sidecar-bound URL on the first line of stdout, so a healthcheck failure can be cross-referenced with the bind log line (today the bind log fires after healthcheck has already started).

    Workaround for affected users (works as of today)

    Force-checkpoint the WALs after fully closing cc-haha:

    import sqlite3
    for db in ['index-v1.sqlite', 'search-index-v1.sqlite', 'trace-index-v1.sqlite']:
        c = sqlite3.connect(
            rf'E:\Users\Duanyi\.claude\cc-haha\db\{db}',
            isolation_level=None,
        )
        c.execute('PRAGMA wal_checkpoint(TRUNCATE)')
        c.close()

    Also reset ~/.claude/desktop-server-state.json to {"lastPort": null} if the previous port is stuck in TIME_WAIT. Restart cc-haha afterwards.


    Happy to open a separate PR for any of these if useful — especially the /health early-return change, since that's the lowest-risk, highest-impact one.

  2. kizemo commented on Sep 30, 2026

    @kizemo
    Author

    Adding one more failure mode to this issue for completeness:

    New symptom (2026-09-30, later today): After sidecar fails with the 500ms healthcheck timeout described above, if the user closes cc-haha and tries to relaunch it, the next launch fails differently — the sidecar process exits with code=1 immediately after spawn and produces zero lines of stdout. The error page then reads:

    Error invoking remote method 'desktop:runtime:get-server-url':
      desktop server did not report healthy at http://127.0.0.1:57917/health
      within 30 seconds: connect ECONNREFUSED 127.0.0.1:57917
    
    No server stdout/stderr was captured before the timeout.
    

    This is much harder to diagnose than the 500ms timeout case, because there's no log trail to inspect. Manual investigation showed:

    • Sidecar binary is fine. Running it manually works:
      claude-sidecar-x86_64-pc-windows-msvc.exe server --app-root "D:/Program Files/Claude Code Haha/resources"
      → [Server] Claude Code API server running at http://127.0.0.1:3456 (stable for 8s+)
      
    • cc-haha/db/*.sqlite-wal was holding 24 MB (14+10+0) at the time of failure. PRAGMA wal_checkpoint(TRUNCATE) drops all three WALs to 0.
    • ~/.claude/desktop-server-state.json had lastPort: 64467 pointing at a leftover socket in CLOSE_WAIT from a previous run. Resetting to {"lastPort": null} lets cc-haha pick a fresh port.
    • Several orphaned Claude Code Haha.exe processes (47052, 5012) from earlier failed launches were still in the process table with HasExited=True (Windows hadn't reaped them).

    After applying all three workarounds (kill zombies, checkpoint WAL, reset port state), cc-haha launched successfully on the very next attempt.

    Workaround for users hitting this ECONNREFUSED variant (in addition to the previous workaround):

    # 1. Fully quit cc-haha (close all windows, including tray)
    # 2. Force-checkpoint all three sidecar DBs
    $utf8NoBom = New-Object System.Text.UTF8Encoding $false
    # (Python version that works with locked shm files:)
    # python -c "import sqlite3; [sqlite3.connect(rf'E:\Users\Duanyi\.claude\cc-haha\db\{n}', isolation_level=None).execute('PRAGMA wal_checkpoint(TRUNCATE)') for n in ['index-v1.sqlite','search-index-v1.sqlite','trace-index-v1.sqlite']]"
    # 3. Reset port state
    [System.IO.File]::WriteAllText(
        'E:\Users\Duanyi\.claude\desktop-server-state.json',
        '{"lastPort": null}',
        (New-Object System.Text.UTF8Encoding $false))
    # 4. Relaunch cc-haha

    Suggestion to upstream: when the sidecar exits with code=1 and no stdout, surface a clearer error in the UI ("sidecar exited during initialization, see /path/to/electron-host.log for details") instead of just "ECONNREFUSED". Right now users have no idea whether it's a port conflict, a missing dependency, or a DB lock.

  3. NanmiCoder commented on Oct 1, 2026

    @NanmiCoder
    Owner

    已在 v0.6.8 修复,请升级后再试。

    原因是被服务端判为非法的工具调用会留在对话记录里,AskUserQuestion 卡片每次回放都用那份输入重建,而选项描述返回的是对象时会抛 React error #31,把整个窗口换成错误页。因为打开的标签会被恢复,所以每次启动都复现。

    修复

    • AskUserQuestion 只渲染它能显示的字符串字段,异常字段不再导致崩溃
    • 每条对话记录外面加了独立的错误边界,一条坏记录不会再拖垮整个窗口

    顺带修的

    • 定时任务文件带 UTF-8 BOM(PowerShell 5.x 写出)或写入中断留下空文件时,列表和创建不再 500;有内容但解析失败仍然报错,且永远不会被覆盖
    • PDF 先读后写的中间状态不再丢失
    • 切换权限模式改用实时模块绑定,不再读到过期值

    如果升级后还有崩溃,麻烦把诊断包发我,里面有崩溃时的会话状态。

    — @NanmiCoder

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions