ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

浏览器中如何实现跨平台伪终端?obsidian-terminal 的 Python PTY 与 ConPTY 主机深度剖析

浏览器中如何实现跨平台伪终端?obsidian-terminal 的 Python PTY 与 ConPTY 主机深度剖析

【免费下载链接】obsidian-terminal

Integrate consoles, shells, and terminals.

项目地址:https://gitcode.com/gh_mirrors/ob/obsidian-terminal
点击查看免费下载

obsidian-terminal 是一个 Obsidian 终端插件,它把真正的 shell 集成进笔记软件:在 macOS、Linux 与 Windows 上,它通过Python PTY 代理和ConPTY 控制主机在浏览器沙箱外托管伪终端(Pseudoterminal),再经 xterm.js 渲染出可交互的跨平台伪终端。本文拆解这套"前端—TS 中间层—Python 宿主"三层架构的核心机制,帮你理解浏览器类应用如何安全、可靠地驱动终端。

为什么浏览器里的终端需要一个 Python 代理?

Obsidian 基于 Electron,插件代码运行在浏览器渲染进程里,无法直接调用fork()、CreatePseudoConsole这类系统接口。终端的"灵魂"——伪终端设备(Unix PTY / Windows ConPTY)——必须由一个真正的操作系统进程持有。

obsidian-terminal 的解法非常直接:🐍

  1. 把 Python 源码直接打进插件 bundle,作为"虚拟模块"导入;
  2. 用python -c <源码> <shell 命令>启动一个PTY 宿主进程,它创建伪终端并跑起目标 shell;
  3. 宿主进程的 stdin/stdout 就是数据通道,另开一条控制通道传"调整窗口大小"这类指令;
  4. TypeScript 中间层负责把宿主的输出接到 xterm.js,把键盘输入写回宿主。

这个统一抽象定义在Pseudoterminal接口(pseudoterminal.ts),只暴露shell、kill、onExit、pipe、resize五个概念,Unix 与 Windows 各自实现,对上层完全透明。

Unix 侧:一个约 460 行的 Python PTY 代理

Unix 平台的宿主是 unix_pseudoterminal.py,逻辑精炼到可以通读全文。

pty.fork() 与非阻塞 master

main()调用pty.fork()一分为二:子进程execvp执行目标 shell,父进程成为"代理",持有 pty master 描述符并立即设为非阻塞(unix_pseudoterminal.py)。

关键设计在文件头注释里说得明白:"代理从不等待写入 pty"。因为如果 shell 正在等自己读输出,代理又阻塞在写 pty 上,两边就永久死锁。所以:

  • pty 输出 → 代理 stdout,始终可读就走;
  • 代理 stdin → pty 的输入放进一个有界缓冲区(_INPUT_BUFFER_LIMIT = 65536字节,unix_pseudoterminal.py),写不进去就停下读 stdin,让背压自然传导回浏览器端。

selector 事件循环:读写复用一套回调

代理用DefaultSelector监听三个描述符(unix_pseudoterminal.py):

描述符处理器职责
pty master_PipePty读出 shell 输出转发到 stdout;在有排队输入时才注册EVENT_WRITE,PTY 有空闲就_flush()
stdin (fd 0)_PipeStdin读宿主写来的键盘输入喂给 pty,缓冲区满时自动"停看"
控制通道 (fd 3)_ProcessCmdIO解析列数x行数指令,调整窗口尺寸

一个细节:macOS 上子进程丢弃输入队列或改变终端模式时,PTY 可能"变空"但 selector 不会上报可写,所以每轮循环都会强制retry()一次(unix_pseudoterminal.py)。

窗口大小调整:fd 3 上的 TIOCSWINSZ

控制通道是第 4 个 stdio 管道(fd 3)。TS 侧每次 resize 只写一行"120x30\n"这样的文本(pseudoterminal.ts),Python 侧解析后执行ioctl(TIOCSWINSZ),内核随即通知 shell 终端尺寸变化,触发SIGWINCH和重排(unix_pseudoterminal.py)。

优雅终止:按进程组发三档信号

宿主退出时先关掉 pty master(内核挂断会话),再对整个进程组按SIGHUP → SIGTERM → SIGKILL逐级施压,每档给 1~2 秒宽限,并用/proc或killpg(pgid, 0)轮询确认组内已无活进程(unix_pseudoterminal.py)。这样即使用户只杀了 shell,它的子孙进程(比如top)也不会变成孤儿。

Windows 侧:ConPTY 主机的握手、认证与进程收容

Windows 没有 POSIX PTY,obsidian-terminal 用ConPTY(Windows 10 1899+ 的伪控制台 API)实现同等能力。宿主 win32_conpty.py 约 3000 行,几乎全靠ctypes调用 Win32 API,亮点不少。

纯 ctypes 实现 CreatePseudoConsole

宿主用 ctypes 绑定conpty.dll的CreatePseudoConsole、ResizePseudoConsole(win32_conpty.py),并按微软winconpty.cpp的移植逻辑构造PSEUDOCONSOLE结构,让CreateProcessW把 shell 挂到控制台宿主上。

它还会优先使用 Windows Terminal 1.22+ 自带的控制台宿主OpenConsole.exe(响应更多终端回复序列),找不到或启动失败就自动回退到系统内置 conhost,并允许 ConPTY 整体失败时降级为纯 ConHost 后端(win32_conpty.py)。

NDJSON 控制管道 + token 认证握手

与 Unix 的"fd 3 写一行文本"不同,ConPTY 宿主使用命名管道\\.\pipe\obsidian-terminal-conpty-<随机>承载 NDJSON 控制消息(pseudoterminal.ts)。为什么需要管道和认证?因为命名管道是全局可见的,任何本地进程都能连上来发kill指令。

握手流程(win32_conpty.py):

  1. 宿主发送hello(或预热的idle),携带 token 与hostPid、childPid;
  2. 插件核对 token 后回发op: "authenticate"响应,宿主限定 5 秒内必须收到,否则拒绝服务(win32_conpty.py);
  3. 之后宿主才发ready事件,并附上一句"宣誓"式认证字符串:create-pseudoconsole+authenticated-control-channel+job-object-assigned—— 明确宣告"已创建伪终端 + 控制通道已认证 + 已分配 Job 对象"(win32_conpty.py)。

token 本身只经环境变量OBSIDIAN_TERMINAL_CONPTY_TOKEN传递,绝不进命令行,避免被进程列表窥探(pseudoterminal.ts)。

挂起启动 + Job 对象:让"杀进程"永远可靠

宿主用CREATE_SUSPENDED创建 shell:进程先被冻住,分配进一个带JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE的 Job 对象,然后才发送hello报出子进程 PID,最后ResumeThread放行(win32_conpty.py)。

好处很实际:

  • 冻结窗口内完成 Job 分配,杜绝"进程跑了但没管住"的竞态;
  • 关闭 Job 即连带杀掉 shell 的全部后代,无需逐个TerminateProcess;
  • ready事件里报的 PID 就是真实运行的子进程,TS 侧可据此校验宿主身份(pseudoterminal.ts)。

数据泵与 resize

会话建立后,三个后台线程各司其职:conpty-output把伪终端输出泵到宿主 stdout,conpty-input把宿主 stdin 写进伪终端输入管道,conpty-control循环处理resize/kill等操作(win32_conpty.py)。

值得注意的是输出泵直接用裸描述符os.write写盘,注释解释了原因:线程在解释器收尾阶段持有sys.stdout.buffer的锁会直接让 CPython 崩溃——这类细节正是"Python 当系统级宿主"的必修课。resize 则通过控制管道的resize操作执行ResizePseudoConsole;若运行在 OpenConsole 宿主上,改发信号管数据包包给它(win32_conpty.py)。

TypeScript 中间层:xterm.js 接入与背压控制

宿主输出只是字节流,真正"像终端"的是 xterm.js 渲染与输入回传,核心函数是pipeShellToTerminal(pseudoterminal.ts),它做了三件关键的事:

  • 切片写入:writeTerminalSliced把大输出切成小块写 xterm,因为 xterm 每次Terminal.write是不可打断的任务,切片才能让渲染帧穿插其间,防止cat big.log时界面卡死;
  • 高/低水位背压:createTerminalOutputBackpressure统计"已写入但未解析"的字节数,超过高水位就暂停来源流,低于低水位再恢复,避免浏览器内存被输出洪水冲爆(pseudoterminal.ts);
  • 输入回传:terminal.onData把 xterm 解析后的按键数据写回shell.stdin,shell 退出时自动解绑。

此外,终端外观选项、多标签、历史保存等功能都建立在这套管道之上,配置入口在 settings-data.ts,可定制的终端行为详见 emulator.ts。

动手跑起来:环境要求与排错要点

想亲自验证这套跨平台伪终端实现,环境要求如下:

平台要求说明
macOS / LinuxPython ≥ 3.9按 profile 配置解释器,点"Check"校验
WindowsPython 可选默认 ConPTY 后端无需 pip 包;ConHost 后端的 resizer 需要psutil、pywinctl(win32_resizer.py)

Windows 上若 Python 不可用、宿主握手超时或 ConPTY 启动失败,插件会自动以 ConHost 模式打开并提示错误;装好 Python 后在插件设置里点"Recheck Python"即可重试 ConPTY(README.md)。相关行为还有专门的自愈脚本 win32-doctor.ts,以及测试用例 test_unix_pseudoterminal.py、test_win32_conpty.py 可对照阅读。

小结

obsidian-terminal 用两个"小而全"的 Python 宿主解决了浏览器应用驱动终端的三个核心难题:

  1. 能力边界:PTY/ConPTY 系统调用交给 OS 子进程,浏览器端只传字节;
  2. 背压与死锁:Unix 侧用非阻塞 master + 有界输入缓冲,Windows 侧用管道泵 + TS 端高低水位,双向流控;
  3. 安全与可靠性:命名管道 token 认证、挂起启动 + Job 收容、三级信号终止,让"跨进程杀终端"和"防本地伪造指令"都有确定答案。

这套"前端渲染、TS 编排、Python 托管"的分工,对任何想在 Web 容器里做终端的项目都是可直接借鉴的完整范式。

【免费下载链接】obsidian-terminal

Integrate consoles, shells, and terminals.

项目地址:https://gitcode.com/gh_mirrors/ob/obsidian-terminal
点击查看免费下载
上一篇:终极指南:如何用Nuclei自包含模板彻底告别依赖地狱
下一篇:Lightweight Charts Pretty Histogram Series 插件:圆角直方图自定义序列实战指南

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进