diff --git a/src/claude/__tests__/executor.test.ts b/src/claude/__tests__/executor.test.ts index 70f4917..e424567 100644 --- a/src/claude/__tests__/executor.test.ts +++ b/src/claude/__tests__/executor.test.ts @@ -581,4 +581,39 @@ describe('ClaudeExecutor', () => { expect(opts.env).toBeUndefined(); }); }); + + describe('result message terminates loop (idle-timeout regression)', () => { + it('should return success immediately when SDK stream hangs after the result message', async () => { + // 复现线上 bug:SDK 发出 result(success) 后不关闭异步迭代器,next() 永不 resolve。 + // 修复前 for-await 会继续阻塞等待下一条永不到来的消息,600s 后被 idle timer 误判 + // 为超时,把已成功的 query 当成错误抛出(日志特征 lastResetSource=msg:result:success)。 + // 修复后收到 result 立即 break,不再调用 next(),query 正常返回成功结果。 + const messages = [ + { type: 'system', subtype: 'init', session_id: 'sess-1', model: 'claude', tools: [] }, + { type: 'result', subtype: 'success', session_id: 'sess-1', result: 'hello', duration_ms: 100 }, + ]; + let nextCallCount = 0; + const returnSpy = vi.fn(() => Promise.resolve({ value: undefined, done: true })); + mockQueryInstance[Symbol.asyncIterator].mockReturnValue({ + next: () => { + nextCallCount++; + if (nextCallCount <= messages.length) { + return Promise.resolve({ value: messages[nextCallCount - 1], done: false }); + } + // 第三次及以后:永不 resolve(模拟 SDK 在 result 后卡住不关闭流) + return new Promise(() => {}); + }, + return: returnSpy, + }); + + const result = await executor.execute(makeInput()); + + expect(result.success).toBe(true); + expect(result.output).toBe('hello'); + // 只应读取 init + result 两条消息,绝不尝试读取第三条(那会永久阻塞) + expect(nextCallCount).toBe(2); + // for-await 通过 break 提前退出时会调用 iterator.return(),触发 SDK 内部清理 + expect(returnSpy).toHaveBeenCalled(); + }); + }); }); diff --git a/src/claude/executor.ts b/src/claude/executor.ts index ab94cd1..8001312 100644 --- a/src/claude/executor.ts +++ b/src/claude/executor.ts @@ -984,6 +984,7 @@ export class ClaudeExecutor { try { // 遍历 SDK 流式消息 + messageLoop: for await (const message of q) { // 每收到消息重置 idle 计时器 resetIdleTimer(`msg:${message.type}${'subtype' in message ? ':' + (message as Record).subtype : ''}`); @@ -1123,7 +1124,12 @@ export class ClaudeExecutor { case 'result': resultMessage = message; - break; + // result 是 query 的终止消息。SDK 偶发在发出 result 后不关闭异步迭代器, + // 导致 for-await 继续阻塞等待永不到来的下一条消息,最终被 idle timer 误判 + // 为超时(即使 query 已成功),把成功结果当成错误抛给用户(见 idle timeout + // lastResetSource=msg:result:success 的线上案例)。收到 result 立即跳出循环, + // 主动结束,避免空转到超时。break 标签确保跳出 for-await 而非仅 switch。 + break messageLoop; default: // tool_progress, stream_event 等其他消息类型 — 记录以便诊断 idle timeout 间隙