Files
lou 0827b2399c 内核框架 v0.1 首次提交: 引导器 / 内核 / 驱动 + 日志系统
引导器 UEFI.boot.py (纯 stdlib, 内核的管家)
  体检 6 项 (解释器/venv/包/驱动目录/PG) / venv 重建 / 包台账 / 内核进程启停记账 / 参数原样透传
  内核日志 / 引导器日志 / 内核 stdio 三条道的查看入口都归它

内核 内核/ (常驻调度 = 甲)
  命令消费 (PG commands + LISTEN/NOTIFY) / 驱动调用仲裁六条 (越权·成环·无人提供·按需拉起·同锁排队·超时收权)
  依赖链巡检 10s / 心跳 300s / 独一份调度锁 (pg_try_advisory_lock 0x65666901)
  扫描: 9 条校验 + 契约匹配 + 拓扑排序; 状态: 快照原子写 + 断电收尸判定
  db.py 是唯一碰 SQL 的文件 (drivers/driver_state/events/scans/commands/calls/kernel_env/kernel_runs)

日志系统 (2026-09-16 完整化, 设计/04-日志系统.md)
  三条道一个文件一种内容: 内核.log (结构化行) / 内核.out.log (命令输出 + 崩溃原文) / 引导器.log (引导器动作)
  驱动日志每轮启动前插分隔头; 门槛 log_level / 轮转 log_max_mb + log_keep / -n · -f · --级别 · -g · --json · --全部
  实现 内核/日志.py (内核与引导器共用一份); 修掉"命令输出混进日志文件" (实证存档 归档/20260916-日志系统重做前/)

驱动样板 (也是写驱动的示范): Json解码 (oneshot) / 样板常驻 (provides 样板:心跳) / 样例消费器 (needs 只发契约名)

代码标点统一为 ASCII (保留界面用的框线 ─│◄▶ 与表格占位符 —); 已用 AST 等价对比证明逻辑零改动

自测 (真机, 不 mock): 进程 (真起进程真收子树) / 内核 58 项 / 配置 / db 182 项 / 日志 86 项
验收: 试跑引导器.py PASS 11 / FAIL 0 / 残留无; uvx pyright 与 uvx basedpyright 均 0 errors 0 warnings
2026-09-16 21:03:48 +08:00

800 lines
31 KiB
Python

"""通用进程库: 启动 / 停止 / 判活 / 日志.
[谁用它]
内核用它管**驱动**进程, 引导器用它管**内核**进程 -- 只此一份实现, 绝不复制
(设计 02 §7 原话: "只此一份实现, 不复制").两套实现 = 各有一套坑, 还各自以为对方对.
[为什么全走 /proc, 不用 ps]
* busybox 的 ps 会截断命令行 (软路由上踩过), 长 cmdline 根本看不全
* `ps | grep` 会把**你自己的排查命令**也算进去, 得到假计数 (踩过好几次)
* /proc/<pid>/cmdline 是 NUL 分隔的 argv 原文, 精确点名, 不用二次解析
[四条铁律 (都是踩过的坑, 改这个文件前先读一遍)]
1. 发信号前必须校验 cmdline -- pid 会被系统复用, 裸 kill <pid> 可能杀到别人的进程
(老板的 GUI 程序就这么被误杀过一次, 被点名批评)
2. start_new_session=True 起独立进程组 -- 停止时能连子树一起收 (杀父不等于杀子树:
python 死了, 它拉起的子进程会变孤儿继续跑,继续写同一个文件)
3. 启动与停止分两条命令, 一次只起一份 -- 同一个 shell 里连着做会留孤儿互抢端口
4. 排查动作本身会破坏现场: 反复起/杀会攒出一堆孤儿, 让人误判成"程序不稳定"
(曾经攒出 13 份隧道孤儿互抢端口).所以本库只做"一次一件事", 不自动重试.
[依赖]
纯 stdlib.内核和引导器都直接 import 这个文件.
"""
from __future__ import annotations
import os
import signal
import subprocess
import sys
import time
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
# ─────────────────────── 判活结果 (判活() 的返回值, 也是 driver_state.state 的词汇表) ───────────────────────
# 五个值各有明确含义, 调用方按它决定"认领 / 收尸 / 不认领":
运行中: str = "running" # /proc/<pid> 在, 且 cmdline 校验通过 -> 是我们那个进程, 认领
已停止: str = "stopped" # 没有 pid 记录, 或 /proc/<pid> 根本不在了 -> 正常态, 幂等
已崩: str = "crashed" # PG 里记着 pid 但进程没了 (断电 / 被杀) -> 该收尸, 按 restart 策略决定要不要拉
被复用: str = "exited" # pid 还在, 但 cmdline 不是我们的 (系统把 pid 分给别人了) -> 不认领, 更不许杀
僵尸: str = "zombie" # 进程其实已死, 但父进程没 wait 回收 (Z 态) -> 杀不动, 只能等父进程收
# /proc/<pid>/stat 里的 utime / stime / starttime 单位是"节拍"(jiffies), 除以它才是秒
时钟频率: int = os.sysconf("SC_CLK_TCK")
# /proc/<pid>/statm 的单位是"页", 乘它才是字节 (拿来做 RSS)
页大小: int = os.sysconf("SC_PAGE_SIZE")
@dataclass
class 进程信息:
"""从 /proc 直读出来的一份进程快照 (读不到就返回 None, 不抛异常).
字段全部来自 /proc/<pid>/{stat,statm,cmdline}, 不来自 PG 里的旧 pid 记录.
"""
pid: int # 进程号
ppid: int # 父进程号 (看进程归属)
pgid: int # 进程组号 (收子树的关键: killpg(-pgid))
state: str # 单字母状态: R 运行 / S 睡眠 / D 不可中断 / Z 僵尸 / T 停止
cmdline: list[str] # argv 原文 (NUL 分隔切好的); 空表 = 读不到 (权限 / 内核线程 / 僵尸)
rss_kb: int # 常驻内存, KB (来自 statm 的第 2 列 * 页大小)
cpu秒: float # 用户态 + 内核态 CPU 时间, 秒 (utime + stime)
启动时刻: float # unix 时间戳 (由 开机秒数 - starttime 反推)
存活秒: float # 已经跑了多久 (秒)
@property
def cmdline文本(self) -> str:
"""空格拼起来的命令行, 给人看 / 写日志用."""
return " ".join(self.cmdline)
@property
def 内存文本(self) -> str:
"""RSS 的人读格式 (KB / MB / GB)."""
if self.rss_kb >= 1024 * 1024:
return f"{self.rss_kb / 1024 / 1024:.2f} GB"
if self.rss_kb >= 1024:
return f"{self.rss_kb / 1024:.1f} MB"
return f"{self.rss_kb} KB"
@property
def 存活文本(self) -> str:
"""存活时长的人读格式 (HH:MM:SS)."""
return 时长文本(self.存活秒)
@dataclass
class 启动结果:
"""启动() 的返回.秒退也算返回 (不是异常), 由调用方决定怎么记账.
字段:
ok: 起成功了 (探活期内没退).
pid: 子进程 pid; spawn 都失败时为 None.
pgid: 子进程的进程组号 (独立会话所以通常 == pid); 没活下来时 None.
exit_code: 秒退时的退出码; 没退则 None.
seconds: 从 spawn 到判定的秒数.
detail: 人读的一句话结论 (给 PG 的 last_error / detail 用, 带原文不吞错).
"""
ok: bool
pid: int | None
pgid: int | None
exit_code: int | None
seconds: float
detail: str
@dataclass
class 停止结果:
"""停止() 的返回.
字段:
ok: 结果干净 (进程本来就不在 / SIGTERM 收工 / SIGKILL 后收干净).
forced: 是否升过级 (用过 SIGKILL).
seconds: 总共等了多久.
detail: 人读结论; pid 被复用时说明"不发信号"的原因.
"""
ok: bool
forced: bool
seconds: float
detail: str
@dataclass
class 命令结果:
"""跑命令() / 跑并转发() 的返回 (给 uv / pip / psql / 探版本这类短命令用).
字段:
code: 退出码 (超时 124, 跑不起来 127, 跟 shell 惯例一致).
seconds: 耗时.
lines: 输出行 (已去掉空行; 跑并转发 时同时转发到了 stdout).
detail: 出错时的一句话原因 (超时才填).
"""
code: int
seconds: float
lines: list[str]
detail: str = ""
# ─────────────────────────────── 底层读 /proc ───────────────────────────────
def 时长文本(: float) -> str:
"""秒 -> HH:MM:SS.负数按 0 处理 (时钟抖动时别出现 -00:00:01)."""
= int(max(, 0.0))
= // 3600
= ( % 3600) // 60
= % 60
return f"{:02d}:{:02d}:{:02d}"
def 读cmdline(pid: int) -> list[str]:
"""读 /proc/<pid>/cmdline: argv 的原文, 元素之间是 NUL 字节 (\0).
参数:
pid: 进程号.
返回:
argv 列表; 进程不在 / 读不到 (权限,内核线程,僵尸) 时返回空表.
说明:
空表**不等于**"进程不在": 僵尸进程的 cmdline 就是空的.
所以要判在不在, 用 判活() / Path("/proc/<pid>").exists(), 别只看这个.
"""
try:
原始 = Path(f"/proc/{pid}/cmdline").read_bytes()
except OSError:
return []
return [.decode("utf-8", "replace") for in 原始.split(b"\0") if ]
def 读stat(pid: int) -> list[str] | None:
"""读 /proc/<pid>/stat, 返回**去掉 pid 和 comm 之后**的字段列表.
参数:
pid: 进程号.
返回:
字段列表, 下标 0 == stat 的第 3 个字段 (state); 读不到返回 None.
为什么这样切:
stat 的第 2 个字段 comm 是括号包着的进程名, **里面可以带空格和右括号**
(如 "(my prog),v2)"), 用 split() 切必错.唯一可靠的切法是取**最后一个**右括号,
后面的部分再 split -- 所以下标要整体减 2 (下标 i 对应 stat 的第 i+3 个字段).
常用下标 (下标 = 字段号 - 3):
[0] state 进程状态 (R/S/D/Z/T)
[1] ppid 父进程
[2] pgrp 进程组 (收子树用)
[11] utime 用户态 jiffies
[12] stime 内核态 jiffies
[19] starttime 进程启动时刻 (相对开机, jiffies)
"""
try:
原文 = Path(f"/proc/{pid}/stat").read_text()
except OSError:
return None
右括号 = 原文.rfind(")")
if 右括号 < 0:
return None
return 原文[右括号 + 1 :].split()
def 读开机秒() -> float:
"""系统开机到现在的秒数 (/proc/uptime 的第 1 列).读不到返回 0."""
try:
return float(Path("/proc/uptime").read_text().split()[0])
except (OSError, ValueError, IndexError):
return 0.0
def 读进程信息(pid: int) -> 进程信息 | None:
"""把 /proc/<pid> 下几份文件拼成一份快照.
参数:
pid: 进程号.
返回:
进程信息; 进程不在 / 字段残缺 / 权限不够时返回 None (不抛).
启动时刻怎么算的:
stat 只给"相对开机时刻"的 starttime (jiffies), 换算:
存活秒 = 开机秒数 - starttime / 时钟频率
启动时刻(unix) = 当前时间 - 存活秒
uptime 和某进程的 starttime 是两个不同时刻读的, 会有毫秒级误差, 够用.
"""
if pid <= 0:
return None
字段 = 读stat(pid)
if 字段 is None or len(字段) < 20:
return None
try:
ppid = int(字段[1])
pgid = int(字段[2])
utime = int(字段[11])
stime = int(字段[12])
启动tick = int(字段[19])
except (ValueError, IndexError):
return None
rss_kb = 0
try:
# statm 第 1 列是虚拟内存页数, 第 2 列才是常驻 (RSS) 页数
= Path(f"/proc/{pid}/statm").read_text().split()
rss_kb = int([1]) * 页大小 // 1024
except (OSError, ValueError, IndexError):
rss_kb = 0
存活秒 = 读开机秒() - 启动tick / 时钟频率
存活秒 = max(存活秒, 0.0)
启动时刻 = time.time() - 存活秒
return 进程信息(
pid=pid,
ppid=ppid,
pgid=pgid,
state=字段[0],
cmdline=读cmdline(pid),
rss_kb=rss_kb,
cpu秒=(utime + stime) / 时钟频率,
启动时刻=启动时刻,
存活秒=存活秒,
)
# ─────────────────────────────── 判活 / 认领 ───────────────────────────────
def 规范化(路径: str | Path) -> str:
"""转成 realpath 绝对路径, 专门用于"这条 cmdline 指的是不是那个文件"的精确比对.
为什么要 realpath:
启动时可能给的是相对路径,带软链的路径,带 ../ 的路径; 而 /proc 里记的是当时的写法.
两边都 realpath 到真实文件再比, 才判得准.
"""
return os.path.realpath(os.fspath(路径))
def 匹配入口(pid: int, 入口: Path) -> bool:
"""argv 里有没有哪个元素 realpath 后正好等于这个入口文件.
参数:
pid: 要校验的进程.
入口: 入口文件的绝对路径 (如 内核/内核.py,驱动/<名>/json解码.py).
返回:
命中 True / 不命中 False.
为什么不写成子串匹配:
子串匹配会把"我的排查命令里提到了这个文件"也算命中 (踩过: 宽松匹配 `*aria2c*x*`
连自己那条 ssh 一起匹配上, 真把自己 shell 杀了).这里只认 realpath 完全相等.
局限:
只认"argv 里直接出现入口文件"这一种写法.`python -m 包` 这类入口 (argv 里没有文件路径)
匹配不上 -- 本项目不用这种写法, 驱动/内核都是 `解释器 + 入口文件` 的形态.
"""
目标 = 规范化(入口)
for in 读cmdline(pid):
if 规范化() == 目标:
return True
return False
def 判活(pid: int | None, 入口: Path | None = None) -> str:
"""判一个进程现在是什么状态 (本库最核心的判定).
参数:
pid: 要判的进程号; None / <=0 直接当"没在跑".
入口: 给了就做 cmdline 校验 (防 pid 复用); 不给只判在不在.
返回:
五个状态之一 (见文件头的常量注释): 运行中 / 已停止 / 已崩 / 被复用 / 僵尸.
判定顺序 (顺序本身有讲究):
1. 没 pid -> 已停止
2. /proc/<pid> 不存在 -> 已崩 (我们以为它在跑, 其实没了)
3. state == Z -> 僵尸 (已经死了, 只是父进程还没回收; 如实报, 不谎报运行中)
4. 给了入口但 cmdline 对不上 -> 被复用 (pid 被系统分给了别人)
5. 其余 -> 运行中
第 4 步放最后: pid 复用概率低但后果严重 (误杀别人), 前面几步都是便宜判断.
"""
if pid is None or pid <= 0:
return 已停止
if not Path(f"/proc/{pid}").exists():
return 已崩
字段 = 读stat(pid)
if 字段 is not None and 字段[0] == "Z":
return 僵尸
if 入口 is not None and not 匹配入口(pid, 入口):
return 被复用
return 运行中
def 按入口找进程(入口: Path) -> list[int]:
"""扫 /proc 所有进程, 把 cmdline 里有这个入口文件的 pid 全找出来.
参数:
入口: 入口文件绝对路径.
返回:
升序 pid 列表; 没有则空表.
为什么不用 ps / pgrep:
见文件头.这里逐 pid 读 /proc/<pid>/cmdline, 顺便跳过自己 (否则"查找命令自己"
可能被算成一个匹配项, 得到假计数).
用途:
引导器拿它找内核进程 (不依赖 PG, PG 挂了也能找); 内核拿它找驱动进程.
"""
: list[int] = []
自己 = os.getpid()
for in Path("/proc").iterdir():
if not .name.isdigit():
continue
pid = int(.name)
if pid == 自己:
continue
if 匹配入口(pid, 入口):
.append(pid)
.sort()
return
def 进程组成员(pgid: int) -> list[int]:
"""列出某个进程组里**还活着**的成员 pid (收残用).
参数:
pgid: 进程组号 (一般来自 进程信息.pgid).
返回:
升序 pid 列表, 不含自己, **不含僵尸**.
为什么跳过僵尸:
僵尸是"已死但没被父进程 wait 回收"的空壳, SIGKILL 也收不掉.
如果算进来, 停止就会永远报"还有 N 个没收掉", 让人以为没停干净 (踩过).
局限:
进程组号会被复用.这个函数只在"刚对自己确认过的进程组发完信号"这个窗口里用,
不要拿去当长期的身份判据.
"""
: list[int] = []
自己 = os.getpid()
for in Path("/proc").iterdir():
if not .name.isdigit():
continue
pid = int(.name)
if pid == 自己:
continue
字段 = 读stat(pid)
if 字段 is None or len(字段) < 3:
continue
if 字段[0] == "Z":
continue
try:
if int(字段[2]) == pgid:
.append(pid)
except ValueError:
continue
.sort()
return
# ─────────────────────────────── 日志读取 ───────────────────────────────
# 注意分工 (2026-09-16 日志系统落地后):
# 内核 / 引导器 里的 "日志 <谁> 看几行 / -f 跟 / 按级别关键词筛 / 认轮转历史份" 都走
# 内核/日志.py (尾/跟) -- 那边带过滤与轮转感知, 是日志系统的实现 (设计 04).
# 本节这两个函数仍是**进程库自己的通用能力**: 从位置读日志 是"秒退只读本次输出"的判据
# (启动() 在用, 别动), 尾日志 / 跟日志 保留给进程层的诊断 (自测进程.py 用 从位置读日志).
def 从位置读日志(路径: Path, 起始: int, 行数: int = 200) -> list[str]:
"""读日志文件里"第 起始 字节之后"的内容.
参数:
路径: 日志文件.
起始: 字节偏移 (启动前记下的文件大小).
行数: 最多返回末尾几行; <=0 表示不限.
返回:
行列表 (读不到返回空表).
为什么需要它:
日志是**追加**写的 (append).进程秒退时要回答"它临死说了什么", 如果直接读整个文件,
会把上一次运行的输出也当成本次死因 (踩过: 退出码 3 的进程, 死因栏写着上一次的 "hi").
所以启动前记下文件大小, 秒退时只读新增的那一段.
"""
try:
with open(路径, "rb") as 句柄:
句柄.seek(max(起始, 0))
数据 = 句柄.read()
except OSError:
return []
= 数据.decode("utf-8", "replace").splitlines()
if 行数 <= 0:
return
return [-行数:]
def 尾日志(路径: Path, 行数: int = 200) -> list[str]:
"""读日志末尾 N 行 (等价 tail -n).
参数:
路径: 日志文件.
行数: 要几行; <=0 返回空表.
返回:
行列表; 文件不存在/读不到 返回空表 (不抛).
说明:
小文件直接整读再切片 (本项目日志没有几十 MB 的, 不做 seek 倒读那套复杂度).
"""
if 行数 <= 0:
return []
try:
原文 = 路径.read_text(errors="replace")
except OSError:
return []
return 原文.splitlines()[-行数:]
def 跟日志(路径: Path) -> int:
"""`-f` 用的实时跟日志: 先吐末尾 10 行, 然后一直跟着新内容打印.
参数:
路径: 日志文件 (可以先不存在, 等它被创建).
返回:
0 (Ctrl-C 中断也返回 0, 不当失败).
实现方式:
轮询文件大小 + 从上次位置读新增字节, 每轮睡 0.3s.
不用 subprocess 起 `tail -f`: 少一个外部依赖, 也好控制"文件被轮转/清空"的情况.
文件被清空/轮转:
大小比上次位置小 -> 位置归 0, 从头读 (否则会一直卡在旧偏移上什么都不出).
"""
for in 尾日志(路径, 10):
print(, flush=True)
try:
位置 = 路径.stat().st_size
except OSError:
位置 = 0
try:
while True:
try:
大小 = 路径.stat().st_size
except OSError:
大小 = 位置
if 大小 < 位置:
位置 = 0
if 大小 > 位置:
try:
with open(路径, "rb") as 句柄:
句柄.seek(位置)
数据 = 句柄.read()
位置 = 句柄.tell()
except OSError:
数据 = b""
if 数据:
sys.stdout.write(数据.decode("utf-8", "replace"))
sys.stdout.flush()
else:
time.sleep(0.3)
except KeyboardInterrupt:
return 0
return 0
# ─────────────────────────────── 启动 ───────────────────────────────
def 启动(
argv: list[str],
cwd: Path,
日志: Path,
env: dict[str, str] | None = None,
入口: Path | None = None,
探活秒: float = 0.6,
分隔: str = "",
) -> 启动结果:
"""拉起一个进程: 独立进程组, stdout/stderr 追加进日志文件, 探活一段时间再判定.
参数:
argv: 完整命令行 (第一个元素是解释器或可执行文件), **不经过 shell**.
cwd: 工作目录 (驱动就起在驱动文件夹里; 内核起在项目根).
env: 完整环境变量字典; None = 继承当前进程的.
日志: stdout/stderr 追加到这个文件 (文件不存在会被创建, 父目录会自动建).
入口: 入口文件绝对路径, 用于探活后做 cmdline 校验 (确认起的是我们要的那个).
探活秒: spawn 后等多久再 poll (默认 0.6s -- 够 "入口不存在/解释器缺" 这类错暴露出来).
分隔: 非空时先在日志里写一条分隔头 (如 "启动 样板常驻 <命令>") -- 日志是追加的,
多轮启动的输出连成一片就分不清"这句是哪一次说的"; 分隔头把它切开,
也顺手给人一个 tail 定位锚点.
返回:
启动结果 (见其注释).**秒退不抛异常**, 而是 ok=False + exit_code + 本次的日志尾巴,
让调用方自己决定怎么记账.
关键决定:
start_new_session=True -> 子进程自己开一个会话/进程组, 停止时能 killpg 连子树一起收
stdin=DEVNULL -> 别让子进程去抢终端输入 (后台跑着还要读键盘会挂住)
stdout/stderr 同一个文件 -> 驱动/内核的原始输出都留着, 出问题时有据可查
先记 起始字节偏移 -> 秒退时只读"这一次"的新增输出 (别冤枉上一次)
"""
起点 = time.monotonic()
if not argv:
return 启动结果(False, None, None, None, 0.0, "argv 为空")
try:
日志.parent.mkdir(parents=True, exist_ok=True)
句柄 = open(日志, "ab")
except OSError as :
return 启动结果(False, None, None, None, time.monotonic() - 起点, f"日志打不开: {}")
if 分隔.strip():
try:
句柄.write(f"\n==== {分隔} {datetime.now().astimezone().isoformat(timespec='seconds')} ====\n".encode("utf-8"))
句柄.flush()
except OSError:
pass
try:
起始 = 日志.stat().st_size # 分隔头之后取, 秒退死因才只读"这一次"的输出
except OSError:
起始 = 0
try:
进程 = subprocess.Popen( # noqa: S603 (argv 由本程序拼, 不经 shell)
argv,
cwd=str(cwd),
stdin=subprocess.DEVNULL,
stdout=句柄,
stderr=subprocess.STDOUT,
start_new_session=True,
env=env,
)
except (OSError, ValueError) as :
句柄.close()
return 启动结果(False, None, None, None, time.monotonic() - 起点, f"spawn 失败: {}")
句柄.close() # 子进程已经持有 fd, 这边及时关掉 (否则孤儿进程会把日志句柄一直拽着)
time.sleep(max(探活秒, 0.0))
= 进程.poll()
= time.monotonic() - 起点
if is not None:
尾部 = " | ".join(从位置读日志(日志, 起始, 5))
if == 0:
说明 = f"启动后 {:.1f}s 就退出 (退出码 0) -- 不像常驻进程"
else:
说明 = f"秒退, 退出码 {}"
if 尾部:
说明 += f", 日志末尾: {尾部}"
return 启动结果(False, 进程.pid, None, , , 说明)
信息 = 读进程信息(进程.pid)
if 信息 is None:
return 启动结果(False, 进程.pid, None, None, , "读不到 /proc, 状态未知")
校验 = ""
if 入口 is not None and not 匹配入口(信息.pid, 入口):
校验 = " (cmdline 里没看到入口文件 -- 起错了?)"
return 启动结果(
True, 信息.pid, 信息.pgid, None, , f"已拉起 pid={信息.pid} pgid={信息.pgid}{校验}"
)
# ─────────────────────────────── 停止 ───────────────────────────────
def _没了(pid: int) -> bool:
"""进程是不是"死透了"(/proc 不在, 或已成僵尸).
僵尸死透了等回收, 再发信号也没意义 -- 所以"停干净"的判据是 _没了() 为真, 不是"kill 成功了".
"""
if not Path(f"/proc/{pid}").exists():
return True
字段 = 读stat(pid)
return 字段 is not None and 字段[0] == "Z"
def _等死(pid: int, 超时: float) -> list[int]:
"""等 pid 死透, 最多等 超时 秒.
参数:
pid: 目标进程.
超时: 最多等多少秒.
返回:
死透了 -> 空表.
超时了 -> 它所在进程组里**还活着**的 pid 列表 (收残目标; 不含自己, 不含僵尸).
"""
结束 = time.monotonic() + max(超时, 0.0)
while True:
if _没了(pid):
return []
if time.monotonic() >= 结束:
break
time.sleep(0.1)
信息 = 读进程信息(pid)
= 信息.pgid if 信息 is not None else pid
return 进程组成员()
def 停止(
pid: int,
入口: Path | None = None,
超时: float = 10.0,
) -> 停止结果:
"""停一个进程 (连同它的子树), 全过程按"宁可不杀, 不可误杀"来.
参数:
pid: 目标进程号.
入口: 给了就先做 cmdline 校验 -- 对不上就判"pid 被复用", **一个信号都不发**.
超时: SIGTERM 之后等多久再升级 SIGKILL, 默认 10s (设计文档的 stop_timeout).
返回:
停止结果 (见其注释).
流程:
1. 判活: 已经不在 (已停止/已崩/僵尸) -> 直接当成功 (幂等, 重复停不报错);
被复用 -> 不杀, 返回 ok=False + 原因;
2. SIGTERM 发给**进程组** killpg(-pgid) -- 连子树一起收 (杀父不等于杀子树);
3. 等 超时 秒;
4. 还活着 -> SIGKILL 给进程组, 再按 pid 逐个收残 (只补刀, 不猜);
5. 复查, 收干净才算 ok.
为什么先 SIGTERM 不直接 SIGKILL:
给进程一个收尾的机会 (刷缓冲区,写最后一条 events,删自己的 pid 文件).
只有赖着不走的才升级.
"""
起点 = time.monotonic()
状态 = 判活(pid, 入口)
if 状态 in (已停止, 已崩, 僵尸):
return 停止结果(True, False, 0.0, "进程本来就不在 (幂等)")
if 状态 == 被复用:
return 停止结果(False, False, 0.0, f"pid {pid} 的 cmdline 不是我们的 (pid 被复用), 不发信号")
信息 = 读进程信息(pid)
= 信息.pgid if 信息 is not None else pid
# ── 自杀防线 (2026-09-16 真踩过) ──
# 目标如果跟我们在**同一个进程组** (典型场景: 引导器用 subprocess 前台起的常驻内核,
# 它的进程组是从调用方继承来的), 对它 killpg 就等于给自己也来一下 --
# 当时 `试跑引导器.py` 里的 `内核 停止` 把整条进程组收了, 脚本自己 exit -15.
# 同组时只发**单进程**信号: 目标的子树归它自己收尾 (驱动都是独立会话, 不会漏).
要打组 = != os.getpgrp()
def 发信号(: int) -> None:
"""平时打整个进程组 (连子树一起收); 同组时只打目标自己 (免得把自己也收了)."""
if 要打组:
os.killpg(, )
else:
os.kill(pid, )
try:
发信号(signal.SIGTERM)
except ProcessLookupError:
return 停止结果(True, False, time.monotonic() - 起点, "发信号前进程已退出")
except PermissionError as :
return 停止结果(False, False, time.monotonic() - 起点, f"没权限发信号: {}")
= _等死(pid, 超时)
if not :
哪打的 = f"进程组 {}" if 要打组 else f"单进程 {pid} (跟我同组, 没打组)"
return 停止结果(True, False, time.monotonic() - 起点, f"SIGTERM 收工 ({哪打的})")
强制: list[int] = []
if 要打组:
try:
os.killpg(, signal.SIGKILL)
强制.append()
except (ProcessLookupError, PermissionError):
pass
for 目标 in :
if 目标 in (os.getpid(), os.getpgrp()):
continue
try:
os.kill(目标, signal.SIGKILL)
强制.append(目标)
except (ProcessLookupError, PermissionError):
continue
= _等死(pid, 3.0)
if :
return 停止结果(
False,
True,
time.monotonic() - 起点,
f"SIGKILL 之后还有 {len()} 个没收掉: {}",
)
return 停止结果(True, True, time.monotonic() - 起点, f"SIGTERM 超时 -> SIGKILL 收干净 (进程组 {})")
# ─────────────────────────────── 跑命令 ───────────────────────────────
def 跑命令(
argv: list[str],
cwd: Path | None = None,
超时: float = 60.0,
env: dict[str, str] | None = None,
) -> 命令结果:
"""跑一条短命令, 收输出, 不转发给用户 (uv pip list / psql / 问解释器版本这类).
参数:
argv: 命令行.
cwd: 工作目录; None = 当前目录.
超时: 秒.超了返回 code=124 (跟 timeout(1) 惯例一致).
env: 环境变量; None = 继承.
返回:
命令结果 (stdout + stderr 合并进 lines, 空行已去掉).
"""
起点 = time.monotonic()
try:
完成 = subprocess.run( # noqa: S603
argv,
cwd=None if cwd is None else str(cwd),
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
timeout=超时,
env=env,
check=False,
)
except subprocess.TimeoutExpired:
return 命令结果(124, time.monotonic() - 起点, [], f"超时 ({超时:.0f}s)")
except (OSError, ValueError) as :
return 命令结果(127, time.monotonic() - 起点, [], f"跑不起来: {}")
= [ for in 完成.stdout.splitlines() if .strip()]
return 命令结果(完成.returncode, time.monotonic() - 起点, )
def 跑并转发(
argv: list[str],
cwd: Path,
尾部行数: int = 200,
env: dict[str, str] | None = None,
) -> 命令结果:
"""跑命令并把输出**实时转发**到 stdout, 同时留末尾若干行给台账.
参数:
argv: 命令行 (内核/驱动的启动命令).
cwd: 工作目录.
尾部行数: 保留多少行给调用方 (给 PG 的 detail 栏用; 失败时贴日志尾巴).
env: 环境变量; 调用方一般会带 PYTHONUNBUFFERED=1 (见下).
返回:
命令结果 (lines = 保留的那几行).
为什么要转发而不是重定向到文件:
老板要能看实时进度, 不接受黑盒等待.管道的另一头是人.
为什么要 PYTHONUNBUFFERED=1:
子进程 (尤其是 python) 往管道写时默认是**块缓冲**, 不 flush 的话几百行输出会憋到进程
退出才一起出来, "实时"就没了.由调用方在 env 里带上这个变量 (本库不擅自改别人的 env).
注意:
这里逐行读管道直到 EOF, 所以会一直阻塞到子进程结束 -- 这正是"前台跑一次"的语义.
要"后台常驻不等它"的话用 启动(), 别用这个.
"""
起点 = time.monotonic()
保留: list[str] = []
try:
进程 = subprocess.Popen( # noqa: S603
argv,
cwd=str(cwd),
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1, # 行缓冲: 子进程一 flush 我们就能收到
env=env,
)
except (OSError, ValueError) as :
return 命令结果(127, time.monotonic() - 起点, [], f"跑不起来: {}")
管道 = 进程.stdout
if 管道 is not None:
for in 管道:
文本行 = .rstrip("\n")
print(文本行, flush=True)
保留.append(文本行)
if len(保留) > 尾部行数:
保留.pop(0)
= 进程.wait()
return 命令结果(, time.monotonic() - 起点, 保留)