Skip to content

[Bug][Critical] lsp_* 工具未检测到 typescript-language-server 未安装,触发 ERR_UNHANDLED_ERROR 进程崩溃 (v0.1.12) #35

@tianxingzhivlog-droid

Description

@tianxingzhivlog-droid

🐛 Bug 描述

在 v0.1.12 的全局安装版本中,Agent 在执行涉及 TS/JS 文件读取的对话(例如 read_file *.tsx)后会自发调用 LSP 工具(lsp_*)。当用户环境中未安装 typescript-language-server 二进制时,整个 openhorse 进程会直接崩溃退出,而不是降级为「LSP 工具不可用」的友好错误。

触发场景

用户正在和 Agent 对话,Agent 读了若干 .ts/.tsx 文件后(没有显式让它跑 LSP),在后续 turn 中自主决定调用 lsp_get_definition / lsp_get_diagnostics 来辅助分析。

实际栈

Turn 5...
构建和 dev 服务都正常启动了,页面也能渲染 HTML。让我检查一下组件代码是否有运行时错误:
  ▸ read_file /Users/yike/docs/log-view/src/components/time... ✓ 2ms
  ▸ read_file /Users/yike/docs/log-view/src/components/time... ✓ 1ms
  ▸ read_file /Users/yike/docs/log-view/src/pages/timeline.... ✓ 1ms

Turn 6...
  ▸ read_file /Users/yike/docs/log-view/src/components/time... ✓ 1ms

Turn 7...
node:events:509
    throw err; // Unhandled 'error' event
    ^

Error [ERR_UNHANDLED_ERROR]: Unhandled error. ('spawn typescript-language-server ENOENT')
    at LspClient.emit (node:events:507:17)
    at ChildProcess.<anonymous> (/Users/yike/.nvm/versions/node/v22.15.1/lib/node_modules/openhorse/dist/tools/lsp.js:42:18)
    at ChildProcess.emit (node:events:518:28)
    at ChildProcess._handle.onexit (node:internal/child_process:291:12)
    at onErrorNT (node:internal/child_process:483:16)
    at process.processTicksAndRejections (node:internal/process/task_queues:90:21) {
  code: 'ERR_UNHANDLED_ERROR',
  context: 'spawn typescript-language-server ENOENT'
}

🌍 环境

openhorse 0.1.12(npm 全局安装)
Node v22.15.1
OS macOS (darwin)
typescript-language-server 未安装(command -v typescript-language-server 返回 not found)

🔍 根因分析

源码:src/tools/lsp.ts:46-78

class LspClient extends EventEmitter {        // ← 继承 EventEmitter
  private process: ChildProcess | null = null;
  
  async start(): Promise<void> {
    const command = this.getLspCommand();
    this.process = spawn(command.cmd, command.args, {});    // ← 未做 binary 预检

    this.process.stderr?.on('data', (data: Buffer) => {
      this.emit('error', data.toString());                      // ← 直接 emit 到自身
    });

    this.process.on('error', (err) => {
      this.emit('error', err.message);                          // ← child ENOENT 也 emit
    });
    
  }
}

而调用方 LspManager.getClient(同文件 ~245 行)只是:

const client = new LspClient(language, projectRoot);
await client.start();
this.clients.set(key, client);

从未给 client 注册 'error' 事件监听器

按 Node EventEmitter 规范,无监听器时 emit('error', …) 会触发 events.js:509throw err —— 导致 整个 REPL 进程被未捕获错误杀掉,用户丢失会话上下文与未保存的工作。

触发路径

  1. Agent 调用 lsp_get_definition(src/tools/lsp.ts:332-371)
  2. lspManager.getClient(language, context.cwd) 创建 LspClient
  3. client.start()spawn('typescript-language-server', ['--stdio'])
  4. 二进制不存在 → child_process 异步 emit 'error' 事件(ENOENT)
  5. this.emit('error', err.message) 转发到 LspClient
  6. 无监听器 → Node 抛 ERR_UNHANDLED_ERROR → 进程 crash

注意:getLspCommand()try/catch 在 tool 层(335-371 的 try { … } catch (err) { return {success:false} })抓不到这次崩溃,因为 ENOENT 是 child_process异步事件,在 await client.start() 已经 resolve 之后才到达。

💥 严重程度

Critical — 任何未预装 LSP 二进制的用户都会在 Agent 自主决策走 LSP 路径时遇到进程级崩溃

  • 触发条件普遍:大多数用户首装 openhorse 时不会同时装 typescript-language-serverpyright
  • 行为不可恢复:整个 REPL 进程退出,用户会话 / 计划 / 状态全部丢失
  • 用户无主动权:Agent 是自主调用 LSP,用户没有"避免触发"的选项

✅ 修复建议(可直接落地)

修复 1:LspClient.start() 做二进制预检 + 改为 Promise.reject 而非 emit

import { spawn, spawnSync } from 'child_process';

private static probeBinary(cmd: string): boolean {
  // 用 `command -v`/`which` 静默探测,避免起 child 才知道不存在
  const res = spawnSync(process.platform === 'win32' ? 'where' : 'which', [cmd], { stdio: 'ignore' });
  return res.status === 0;
}

async start(): Promise<void> {
  const command = this.getLspCommand();

  // 预检 1:二进制是否存在
  if (!LspClient.probeBinary(command.cmd)) {
    throw new Error(
      `LSP binary "${command.cmd}" not found in PATH. ` +
      `Install it first: npm i -g ${command.cmd}` +
      (command.cmd === 'typescript-language-server' ? ' typescript' : '')
    );
  }

  this.process = spawn(command.cmd, command.args, {
    cwd: this.projectRoot,
    stdio: ['pipe', 'pipe', 'pipe'],
  });

  // 关键:把 child 的 error 转成 Promise reject + 关闭 client,
  // 避免裸 emit 'error' 导致 Node 抛 ERR_UNHANDLED_ERROR
  this.process.on('error', (err: NodeJS.ErrnoException) => {
    const msg = err.code === 'ENOENT'
      ? `LSP binary "${command.cmd}" failed to start (ENOENT). Is it installed and in PATH?`
      : `LSP process error: ${err.message}`;

    // 用 Promise 状态而不是 emit,或在 emit 前确认有监听器
    if (this.listenerCount('error') === 0) {
      // 没人听就只记 log,不要 crash
      console.warn(`[LSP] ${msg}`);
    } else {
      this.emit('error', msg);
    }
    this.process = null;
  });

  this.process.stderr?.on('data', (data: Buffer) => {
    // stderr 不该走 'error' 通道(那是 unhandled-crash 通道),改为 'stderr' 自定义事件
    if (this.listenerCount('stderr') > 0) {
      this.emit('stderr', data.toString());
    }
  });

  this.process.stdout?.on('data', (data: Buffer) => {
    this.handleData(data.toString());
  });
  
}

要点:

  1. start() 同步抛,让 await client.start() 在 tool 层的 try/catch 中正常捕获
  2. 绝不要把 stderr 数据走 'error' 事件(那是 Node 保留通道,会触发 crash)
  3. 即便走 'error',先 listenerCount('error') === 0 再 emit,保底不崩溃

修复 2:LspManager.getClient 也加 error listener 作为最后兜底

async getClient(language: string, projectRoot: string): Promise<LspClient> {
  const key = `${language}:${projectRoot}`;
  if (!this.clients.has(key)) {
    const client = new LspClient(language, projectRoot);

    // 兜底:确保 client 永远有 'error' 监听,避免裸 emit 杀进程
    client.on('error', (msg) => {
      console.warn(`[LSP:${language}] ${msg}`);
    });

    try {
      await client.start();
    } catch (err) {
      // start() 失败不要缓存坏 client
      throw err;
    }
    this.clients.set(key, client);
  }
  return this.clients.get(key)!;
}

修复 3:Tool 层友好降级

lspGetDefinitionTool.execute 已经有 try/catch,但 v0.1.12 当前实现里 client.start() 的 ENOENT 是异步抛的,绕过了 try。修复 1 让它变同步抛之后,这里会自动捕获并返回:

return { success: false, output: '', error: 'LSP unavailable: typescript-language-server not installed' };

Agent 拿到 success: false 后会按 Issue #21 已有的 Strategy Tracker 路径切换到其他工具(grep / read_file)继续工作,用户无感知

修复 4(可选):在 init 流程或 /status 中加 LSP 可用性提示

LSP probes:
  typescript-language-server  ✗ not installed  (npm i -g typescript-language-server typescript)
  pyright                     ✗ not installed  (npm i -g pyright)

让用户提前知情,而非首次踩到才发现。

📐 影响范围

  • 直接受影响:所有未装 LSP 二进制的用户(几乎是全部首装用户)
  • 受影响版本:至少 v0.1.10 引入 LSP(commit d51b7d9 Phase 6 起),v0.1.12 仍未修
  • 修改文件:src/tools/lsp.ts(单文件,约 ±30 行)
  • 风险:无 — 修复纯粹是把异常路径接住,不改变 happy path 行为

🧪 验证步骤

  1. which typescript-language-server 确认未安装
  2. 启动 openhorse,让 Agent 读若干 .ts/.tsx 文件
  3. 观察 Agent 在后续 turn 调用 lsp_get_* 工具
  4. 修复前:进程 ERR_UNHANDLED_ERROR 崩溃
  5. 修复后:工具返回 {success: false, error: "LSP unavailable: …"},REPL 继续运行,Agent 自动切换到 grep / read_file 继续

这是我在真实使用 v0.1.12 时撞上的 Critical bug,直接导致一次长对话(7+ turns)被强制中断。报告中提到的修复方案我都在源码层验证过可行,如果维护者认可,我可以直接提一个 PR(预计 +30 / -5 行,只动 src/tools/lsp.ts)。

关联:也许可以一并把 issue #32#3.10 / #4.3(测试覆盖未到 UI/异常路径)合在 v0.1.13 一并解决。

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions