嵌入式 PTY 终端里进程监控的错误路——pid 藏起来了,但它的子孙还在你树里
做一个 macOS 原生应用,里面嵌了一个真实的 PTY 终端(libghostty-spm 跑 Ghostty surface),想在 UI 上加一条「控制台」——实时显示当前运行命令的 RSS 内存、监听端口、以及应用崩溃后残留的孤儿进程。
需求清晰,但一翻终端库的 API,发现TerminalViewState/surface 只暴露字体、配置等界面属性,根本没有pid/processIdentifier字段。grep 了整个 SPM 包,确认无误。
于是有人得出结论:「必须扩展 Rust 核心,用 sysinfo + lsof 拿进程数据,再通过 UniFFI 桥接到 Swift。」
这个结论——是错的。
现象
终端库封装了 PTY exec 后端,login → shell → yourCommand 整条链已经在跑,你能看见进程在 Activity Monitor 里,但在 Swift 层完全拿不到入口 pid,导致无法做任何进程级操作。
根因
这是一个「视角盲区」造成的误判。大家把注意力放在「终端库有没有暴露 pid」,却忽略了另一个事实:
PTY 的子进程是你自己 app 的后代(descendants)。
终端库调用 exec/fork 的时候,新进程的父进程仍然是你的 app 进程(getpid())。login、shell、你跑的那条命令,都挂在你自己的进程树下。你不需要终端库告诉你 pid——你走自己的进程树就能找到它们。
终端库不提供 pid API ≠ 进程对你不可见。Darwin 内核的libproc允许任何进程枚举全系统 pid 并按父子关系建树。你自己的子孙进程你随时可以 walk,根本不需要 Rust。
在判断「必须引入新语言/新依赖」之前,先问一句:「这个数据从我自己的进程树里能不能拿到?」
正解
纯 Swift,链接 Darwin 的libproc.h,整个监控逻辑不超过 200 行:
import Darwin
// 1. 枚举全系统 pid
var pids = [pid_t](repeating: 0, count: 4096)
let count = proc_listpids(UInt32(PROC_ALL_PIDS), 0, &pids, Int32(pids.count * MemoryLayout<pid_t>.size))
// 2. 对每个 pid 拉取父子关系 + RSS
var info = proc_bsdinfo()
proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, &info, Int32(MemoryLayout.size(ofValue: info)))
// info.pbi_ppid → 父 pid;info.pbi_start_tvsec → 启动时间
var taskInfo = proc_taskinfo()
proc_pidinfo(pid, PROC_PIDTASKINFO, 0, &taskInfo, Int32(MemoryLayout.size(ofValue: taskInfo)))
// taskInfo.pti_resident_size → RSS bytes
// 3. 建 childMap,取 getpid() 的所有后代
// 4. 用 cwd + argv 匹配到具体 run,汇总子树 RSS端口检测用lsof:
// lsof -nP -a -p <pidList joined by ,> -iTCP -sTCP:LISTEN
// 解析 NAME 列,形如 *:5173,提取端口号孤儿进程检测:每次 poll 把{pid, startTime, command, cwd}持久化到 Application Support。下次启动时,若某条记录的 pid 仍存活、启动时间匹配(±2s)、argv 也匹配,则判定为孤儿,用kill(-getpgid(pid), SIGTERM)终止整个进程组。三路校验防止 pid 复用误杀。
proc_listpids枚举全系统 pidPROC_PIDTBSDINFO拿父子关系 + 启动时间PROC_PIDTASKINFO拿 RSSPROC_PIDVNODEPATHINFO拿 cwdsysctl(KERN_PROCARGS2)拿 argv- 在约 2s 的 Timer 里 off main thread 跑,结果 publish 回 main
排查验证
pgrep -P <app_pid>列出终端的 login/shell 子进程,证明它们确实是 app 的直接后代。- 跑一条开发服务器命令,控制台 bar 显示非零 MB +
:5173端口 chip,说明 RSS 和端口采集正常。 - 模拟崩溃(强杀 app 后重启),孤儿检测触发 SIGTERM,进程消失。
一句话外卖
终端库不暴露 pid,不等于进程对你不可见——PTY 子进程永远是你 app 的后代,Darwin libproc 让你随时 walk 自己的进程树,引入新语言之前先问「我树里有没有」。