148 lines
5.5 KiB
Python
148 lines
5.5 KiB
Python
"""
|
||
父进程死亡防护(PR_SET_PDEATHSIG)测试
|
||
|
||
背景与语义见 gateway/src/chongming/app/core/parent_death.py:进程树 onefile 引导 →
|
||
granian master → worker(fork)。上游被 SIGKILL 时 worker 会成孤儿(继续监听
|
||
端口、挂在服务发现里,其它网关派发到"半死副本"每次白吃 2 秒超时)。守卫让内核
|
||
在父进程死亡时给本进程发 SIGTERM(走既有优雅停机摘除注册)。
|
||
|
||
验证(三层 fork 复刻真实进程树:本测试(引导) → 中间进程(master) → 孙进程(worker)):
|
||
1. 关闭开关 CHONGMING_NO_PARENT_GUARD=1 → 不生效(返回 False)
|
||
2. 中间进程 SIGKILL 自己(模拟 master 被 -9,内核不可捕获)→ 已设守卫的孙进程
|
||
收到 SIGTERM 并退出
|
||
3. 对照:同样结构但不设守卫 → 孙进程收不到信号(证明信号确实来自 PDEATHSIG,
|
||
而不是别的东西清理孤儿)
|
||
4. 幂等:重复调用不重复 prctl
|
||
|
||
运行: uv run python test/test_parent_death.py
|
||
"""
|
||
import importlib.util
|
||
import os
|
||
import signal
|
||
import sys
|
||
import tempfile
|
||
import time
|
||
from pathlib import Path
|
||
|
||
ROOT = Path(__file__).parent.parent
|
||
_PD_PATH = ROOT / "gateway" / "src" / "chongming" / "app" / "core" / "parent_death.py"
|
||
|
||
# 独立加载(parent_death 无包内依赖,避免连带导入整个 app 包)
|
||
_spec = importlib.util.spec_from_file_location("chongming.app.core.parent_death", _PD_PATH)
|
||
pd = importlib.util.module_from_spec(_spec)
|
||
sys.modules["chongming.app.core.parent_death"] = pd
|
||
_spec.loader.exec_module(pd) # type: ignore[union-attr]
|
||
|
||
failures = []
|
||
|
||
|
||
def check(name, cond, detail=""):
|
||
status = "PASS" if cond else "FAIL"
|
||
print(f"[{status}] {name}" + (f" — {detail}" if detail else ""))
|
||
if not cond:
|
||
failures.append(name)
|
||
|
||
|
||
# ========== 1. 关闭开关 ==========
|
||
os.environ[pd.GUARD_OFF_ENV] = "1"
|
||
check("关闭开关打开时不生效", pd.guard_parent_death() is False)
|
||
os.environ.pop(pd.GUARD_OFF_ENV, None)
|
||
|
||
# ========== 2/3. 三层 fork:守卫组 / 对照组 ==========
|
||
tmp = Path(tempfile.mkdtemp(prefix="parent_death_test_"))
|
||
|
||
|
||
def scenario(name: str, use_guard: bool) -> dict:
|
||
"""中间进程 SIGKILL 自己,观察孙进程是否收到 SIGTERM。"""
|
||
d = tmp / name
|
||
d.mkdir()
|
||
ready, dead, pidf = d / "ready", d / "dead", d / "gc.pid"
|
||
|
||
middle = os.fork()
|
||
if middle == 0: # 中间进程(master 角色)
|
||
gc = os.fork()
|
||
if gc == 0: # 孙进程(worker 角色)
|
||
def _on_term(*_):
|
||
dead.write_text("term", encoding="utf-8")
|
||
os._exit(0)
|
||
|
||
signal.signal(signal.SIGTERM, _on_term)
|
||
if use_guard:
|
||
pd.guard_parent_death()
|
||
pidf.write_text(str(os.getpid()), encoding="utf-8")
|
||
ready.write_text("1", encoding="utf-8")
|
||
time.sleep(15) # 等信号;超时说明没收到
|
||
os._exit(1)
|
||
for _ in range(250): # 等孙进程就绪,再硬杀自己
|
||
if ready.exists():
|
||
break
|
||
time.sleep(0.02)
|
||
os.kill(os.getpid(), signal.SIGKILL)
|
||
os._exit(1) # 不会走到
|
||
|
||
os.waitpid(middle, 0) # 中间进程必死于 SIGKILL
|
||
gc_pid = int(pidf.read_text(encoding="utf-8"))
|
||
for _ in range(100): # 最多等 5s
|
||
if dead.exists():
|
||
break
|
||
time.sleep(0.05)
|
||
got = dead.exists()
|
||
try: # 清理(对照组孙进程仍活着)
|
||
os.kill(gc_pid, signal.SIGKILL)
|
||
except ProcessLookupError:
|
||
pass
|
||
return {"got_term": got, "gc_pid": gc_pid}
|
||
|
||
|
||
guarded = scenario("guarded", use_guard=True)
|
||
check(
|
||
"master(SIGKILL) 死后,已设守卫的 worker 收到 SIGTERM 退出",
|
||
guarded["got_term"],
|
||
f"gc={guarded['gc_pid']}",
|
||
)
|
||
|
||
control = scenario("control", use_guard=False)
|
||
check(
|
||
"对照组(未设守卫)收不到信号 —— 证明退出确实来自 PDEATHSIG",
|
||
not control["got_term"],
|
||
f"gc={control['gc_pid']}",
|
||
)
|
||
|
||
# ========== 4. 幂等(在子进程里做:避免给测试进程本身设 PDEATHSIG) ==========
|
||
pid = os.fork()
|
||
if pid == 0:
|
||
a = pd.guard_parent_death()
|
||
b = pd.guard_parent_death()
|
||
os._exit(0 if (a is True and b is True) else 1)
|
||
_, status = os.waitpid(pid, 0)
|
||
check("重复调用幂等(不重复 prctl)", os.waitstatus_to_exitcode(status) == 0)
|
||
|
||
# ========== 5. fork 继承回归(master 先设 → worker 仍必须自己设上) ==========
|
||
# 背景:若用模块级"已设置"标志位做幂等,fork 的子进程会继承 True 而短路——
|
||
# 但内核在 fork 时恰好清零 PDEATHSIG,于是 worker 其实没设上(本轮实测到过)。
|
||
# 这里父进程先真实设置,再 fork,断言子进程调用守卫时**内核状态确实被设上**。
|
||
libc5 = pd._load_libc()
|
||
pd.guard_parent_death() # 父进程(本测试进程)真实设置一次
|
||
pid = os.fork()
|
||
if pid == 0:
|
||
cleared = pd._current_pdeathsig(libc5) == 0 # fork 后内核已清零(不继承)
|
||
ok = pd.guard_parent_death() is True # 回归点:不能因父进程设过而短路
|
||
set_now = pd._current_pdeathsig(libc5) == int(signal.SIGTERM)
|
||
os._exit(0 if (cleared and ok and set_now) else 1)
|
||
_, status = os.waitpid(pid, 0)
|
||
libc5.prctl(pd._PR_SET_PDEATHSIG, 0) # 清除测试进程自己的设置,避免副作用
|
||
check(
|
||
"父进程设过后,fork 的子进程仍会真实设置 PDEATHSIG",
|
||
os.waitstatus_to_exitcode(status) == 0,
|
||
)
|
||
|
||
import shutil
|
||
|
||
shutil.rmtree(tmp, ignore_errors=True)
|
||
|
||
print("\n" + ("=" * 40))
|
||
if failures:
|
||
print(f"❌ {len(failures)} 项未通过: {failures}")
|
||
sys.exit(1)
|
||
print("✅ 全部通过")
|