返回博客

嵌入式 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())。loginshell、你跑的那条命令,都挂在你自己的进程树下。你不需要终端库告诉你 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 复用误杀。

正解:Darwin libproc 纯 Swift,不需要 Rust/UniFFI
  • proc_listpids 枚举全系统 pid
  • PROC_PIDTBSDINFO 拿父子关系 + 启动时间
  • PROC_PIDTASKINFO 拿 RSS
  • PROC_PIDVNODEPATHINFO 拿 cwd
  • sysctl(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 自己的进程树,引入新语言之前先问「我树里有没有」。