问题描述 / Issue Description
在使用较长会话后,执行 /exit 或输入 exit 时,终端会无响应并挂起,需强制关闭终端窗口。同时,退出后系统防火墙会报告来自已终止进程的孤立 MCP 连接。
After extended sessions, executing /exit or typing exit causes the terminal to hang and become unresponsive, requiring the terminal window to be force-closed. Additionally, the system firewall reports orphaned MCP connections from terminated processes after exit.
根本原因 / Root Cause
1. TTY 阻塞导致退出挂起 / TTY Block Causes Exit Hang
_cursor_position_unix() 在 tty.setcbreak(fd) 之后调用 os.read(fd, 32)。虽然上游代码已引入 select.select() 超时,但在以下情况 os.read() 仍可能阻塞:
- asyncio 任务取消信号无法中断
os.read() 系统调用
prompt_toolkit 的 stdin reader 与 _cursor_position_unix() 竞争读取 CPR 响应时,数据可能在 select 返回后到 os.read 执行前被消耗
_cursor_position_unix() calls os.read(fd, 32) after tty.setcbreak(fd). Although upstream code already introduced a select.select() timeout, os.read() can still block because:
- asyncio task cancellation cannot interrupt the
os.read() system call
prompt_toolkit's stdin reader races with _cursor_position_unix() for the CPR response; data may be consumed between select returning and os.read executing
2. TTY 恢复不完整 / Incomplete TTY Restoration
ensure_tty_sane() 恢复 c_lflag 中的 ISIG | IEXTEN | ICANON | ECHO,但未重置 c_cc 中的 VMIN/VTIME。若进程在 cbreak 模式下崩溃或挂起,终端会保持在 VMIN=1, VTIME=0 状态,导致 Ctrl+C 发送字节 0x03 而非 SIGINT。
ensure_tty_sane() restores ISIG | IEXTEN | ICANON | ECHO in c_lflag, but does not reset VMIN/VTIME in c_cc. If the process crashes or hangs in cbreak mode, the terminal remains in VMIN=1, VTIME=0 state, causing Ctrl+C to send byte 0x03 instead of SIGINT.
3. MCP 客户端泄漏 / MCP Client Leak
KimiToolset.cleanup()(负责关闭所有 fastmcp.Client 连接)在 KimiCLI.shutdown_background_tasks() 或 shell 拆卸路径中从未被调用。这导致 stdio/WebSocket 传输在 CLI 进程退出后仍保持连接。
KimiToolset.cleanup(), which closes all fastmcp.Client connections, is never invoked in KimiCLI.shutdown_background_tasks() or the shell teardown path. This leaves stdio/WebSocket transports connected after the CLI process exits.
修复方案 / Proposed Fix
-
在 _cursor_position_unix() 中使用 os.set_blocking(fd, False) 使读取非阻塞,并在 finally 中恢复
-
在 ensure_tty_sane() 中重置 VMIN=1, VTIME=0
-
在 shutdown_background_tasks() 中调用 KimiToolset.cleanup()
-
Use os.set_blocking(fd, False) in _cursor_position_unix() to make reads non-blocking, restoring it in finally
-
Reset VMIN=1, VTIME=0 in ensure_tty_sane()
-
Call KimiToolset.cleanup() in shutdown_background_tasks()
环境 / Environment
- macOS 15.7.3 (Ghostty 1.3.1)
- Python 3.13.7
- kimi-cli 1.37.0
- prompt-toolkit 3.0.52
问题描述 / Issue Description
在使用较长会话后,执行
/exit或输入exit时,终端会无响应并挂起,需强制关闭终端窗口。同时,退出后系统防火墙会报告来自已终止进程的孤立 MCP 连接。After extended sessions, executing
/exitor typingexitcauses the terminal to hang and become unresponsive, requiring the terminal window to be force-closed. Additionally, the system firewall reports orphaned MCP connections from terminated processes after exit.根本原因 / Root Cause
1. TTY 阻塞导致退出挂起 / TTY Block Causes Exit Hang
_cursor_position_unix()在tty.setcbreak(fd)之后调用os.read(fd, 32)。虽然上游代码已引入select.select()超时,但在以下情况os.read()仍可能阻塞:os.read()系统调用prompt_toolkit的 stdin reader 与_cursor_position_unix()竞争读取 CPR 响应时,数据可能在select返回后到os.read执行前被消耗_cursor_position_unix()callsos.read(fd, 32)aftertty.setcbreak(fd). Although upstream code already introduced aselect.select()timeout,os.read()can still block because:os.read()system callprompt_toolkit's stdin reader races with_cursor_position_unix()for the CPR response; data may be consumed betweenselectreturning andos.readexecuting2. TTY 恢复不完整 / Incomplete TTY Restoration
ensure_tty_sane()恢复c_lflag中的ISIG | IEXTEN | ICANON | ECHO,但未重置c_cc中的VMIN/VTIME。若进程在 cbreak 模式下崩溃或挂起,终端会保持在VMIN=1, VTIME=0状态,导致 Ctrl+C 发送字节0x03而非 SIGINT。ensure_tty_sane()restoresISIG | IEXTEN | ICANON | ECHOinc_lflag, but does not resetVMIN/VTIMEinc_cc. If the process crashes or hangs in cbreak mode, the terminal remains inVMIN=1, VTIME=0state, causing Ctrl+C to send byte0x03instead of SIGINT.3. MCP 客户端泄漏 / MCP Client Leak
KimiToolset.cleanup()(负责关闭所有fastmcp.Client连接)在KimiCLI.shutdown_background_tasks()或 shell 拆卸路径中从未被调用。这导致 stdio/WebSocket 传输在 CLI 进程退出后仍保持连接。KimiToolset.cleanup(), which closes allfastmcp.Clientconnections, is never invoked inKimiCLI.shutdown_background_tasks()or the shell teardown path. This leaves stdio/WebSocket transports connected after the CLI process exits.修复方案 / Proposed Fix
在
_cursor_position_unix()中使用os.set_blocking(fd, False)使读取非阻塞,并在finally中恢复在
ensure_tty_sane()中重置VMIN=1, VTIME=0在
shutdown_background_tasks()中调用KimiToolset.cleanup()Use
os.set_blocking(fd, False)in_cursor_position_unix()to make reads non-blocking, restoring it infinallyReset
VMIN=1, VTIME=0inensure_tty_sane()Call
KimiToolset.cleanup()inshutdown_background_tasks()环境 / Environment