终端内部原理
本文档介绍终端架构,包括 dtach 会话、WebSocket 协议和常见问题。
概述
Vibora 的终端系统有三层:
- 前端 — xterm.js 终端模拟器 + MobX State Tree 状态管理
- WebSocket — 前端和服务器之间的实时 I/O 多路复用
- 后端 — bun-pty 进行 PTY 管理 + dtach 提供持久化
dtach 会话生命周期
终端由 dtach 支持以实现持久化。理解其生命周期至关重要:
创建 (dtach -n)
bash
dtach -n /path/to/socket /bin/bash这会:
- 在指定路径创建 Unix socket
- 作为子进程生成 shell
- 立即退出 — 创建进程是短暂的
附加 (dtach -a)
bash
dtach -a /path/to/socket这会:
- 连接到现有 socket
- 建立长期连接
- 在 PTY 和附加的客户端之间转发 I/O
关键洞察
创建和附加是独立的进程。 创建进程立即退出——不要持有其引用并期望持续的 I/O。
WebSocket 协议
终端 I/O 通过 /ws/terminal 的单个 WebSocket 连接多路复用。
消息类型
客户端 → 服务器:
typescript
// 附加到终端
{ type: "attach", terminalId: string }
// 向终端发送输入
{ type: "input", terminalId: string, data: string }
// 调整终端大小
{ type: "resize", terminalId: string, cols: number, rows: number }
// 从终端分离
{ type: "detach", terminalId: string }服务器 → 客户端:
typescript
// 终端输出
{ type: "output", terminalId: string, data: string }
// 终端已创建
{ type: "terminal:created", terminal: Terminal }
// 终端已销毁
{ type: "terminal:destroyed", terminalId: string }
// 错误
{ type: "error", terminalId?: string, message: string }连接流程
1. 客户端打开到 /ws/terminal 的 WebSocket
2. 客户端为每个可见终端发送 attach
3. 服务器附加到 dtach 会话
4. 服务器流式发送缓冲的输出
5. 持续的 I/O 双向流动
6. 断开连接时,服务器分离但会话保持MobX State Tree 模型
前端使用 MobX State Tree 管理终端状态:
typescript
const Terminal = types.model("Terminal", {
id: types.identifier,
name: types.string,
tabId: types.maybeNull(types.string),
taskId: types.maybeNull(types.string),
cwd: types.maybeNull(types.string),
isAttached: types.optional(types.boolean, false),
})
const TerminalStore = types.model("TerminalStore", {
terminals: types.map(Terminal),
activeTerminalId: types.maybeNull(types.string),
})乐观更新
创建终端时,我们使用临时 ID:
typescript
// 1. 使用 tempId 创建
const tempId = `temp-${Date.now()}`
store.addTerminal({ id: tempId, name: "New Terminal" })
// 2. POST 到服务器
const response = await createTerminal({ name: "New Terminal" })
// 3. 用 realId 替换 tempId
store.replaceTerminalId(tempId, response.id)缓冲区管理
服务器为每个终端维护输出缓冲区:
typescript
class BufferManager {
private buffers: Map<string, string[]> = new Map()
private maxLines = 10000
append(terminalId: string, data: string) {
// 分行,维护最大缓冲区大小
}
getBuffer(terminalId: string): string {
// 返回缓冲的输出以供附加时重放
}
}当客户端附加时,缓冲的输出会被重放以显示最近的历史记录。
常见问题
白屏竞态条件
症状: 终端显示白屏,尤其在桌面应用中。
原因: TerminalSession 中 start() 和 attach() 之间的竞态条件。
根本原因: start() 方法将短暂的 dtach -n PTY 存储在 this.pty 中。当 attach() 检查 if (this.pty) return 时,它会提前返回,认为附加已经完成。
修复: 在 start() 中使用局部变量存储创建 PTY。只有 attach() 应该设置 this.pty。
教训: 永远不要混淆创建进程和附加进程。
僵尸 dtach Socket
症状: "Socket already exists" 错误。
原因: 非正常关闭后留下的 dtach socket 文件。
修复: 检查并清理陈旧的 socket:
typescript
if (fs.existsSync(socketPath)) {
// 尝试连接——如果失败,socket 是陈旧的
try {
// 尝试连接
} catch {
fs.unlinkSync(socketPath)
}
}输出缓冲间隙
症状: 附加到终端时缺少输出。
原因: 如果未启用缓冲,服务器启动和客户端附加之间生成的输出可能丢失。
修复: 始终从 PTY 创建时就开始缓冲输出,而不是等到客户端附加。
调试
查看终端日志
bash
grep '"src":"PTYManager"' ~/.vibora/vibora.log | tail -50
grep '"src":"TerminalSession"' ~/.vibora/vibora.log | tail -50检查 dtach Socket
bash
ls -la ~/.vibora/worktrees/*/sockets/调试 WebSocket 消息
启用调试日志:
bash
DEBUG=1 mise run dev然后在浏览器控制台中查看 WebSocket 消息日志。
