【任務內容】CM-1229 E2E 實測發現:真 image × gunicorn 下業務埋點觸發 tamper,_terminate_process() killpg(SIGTERM) 送達 master 但 master catch SIGTERM 走 graceful handler 未關閉,反而 respawn worker,服務照常回 200——竄改後可繼續使用直到下次重啟,D8/D11「立即終止」承諾在主承載模式不成立。落點(FS 標記/DB 事件/forensic)皆正常,只差 master 真的死。修法候選:SIGTERM 改 SIGQUIT/SIGKILL(最小改動)。開工先跑 C-1(源碼模式縮短抽查間隔×兩承載)定 scheduler 路徑是否同病,再動刀;修完真 image 重現 C-2 步驟驗死透。完整證據見 CM-1229 卡 2026-08-16 E2E 第一輪回報。
commit 7cc7989e(BE feature/FR-064,未 push)。
卡上(與 _terminate_process docstring)的假設是「killpg 送達 master,master catch SIGTERM 走 gunicorn graceful handler,實際 respawn」。實測推翻。
容器內 binary 被 entrypoint exec 成 PID 1,master 的 pgid 也是 1。而 killpg(1, sig) = kill(-1, sig) ——那是 POSIX 的廣播特例,Linux 明文把 PID 1 排除在廣播對象之外。
在真 image guidant-ai-be:1.14.0 內實測(image 無 python,用內建 perl 探針):
[PID1] pid=1 pgid=1
[child] killpg(-1, TERM) 送達對象數=0 ← master 全程沒收到任何訊號
[child] kill(1, QUIT) 送達對象數=1 ← 指名才送得到,且 handler 有跑
[child] kill(1, KILL) 回傳=1 ← syscall「成功」但…
[PID1] 收到 SIGQUIT
[PID1] 4 秒後仍存活 ← …SIGKILL 對 PID1 被核心靜默忽略
上一棒讀 /proc/1/status 的 SigCgt bit15 已設,正確證明了「master 有 SIGTERM handler」,但被讀成「master 收到了訊號並用 handler 處理掉」——有能力接收 ≠ 實際收到。
連帶結論:卡上的候選修法「SIGTERM 改 SIGKILL(不可 catch 必死)」對 PID 1 無效,改下去只會從「不死」變成「還是不死且更難查」。
用容器化 harness(真 gunicorn 26 pre-fork、PID1=master、呼叫真的 _terminate_process)跑兩承載:
| 路徑 | 修前 | 修後 |
|---|---|---|
| scheduler(master 執行緒自己發) | 死透 exit=1 | 死透 exit=1 |
| Flask dev server(單 process) | 死透 exit=1 | 死透 exit=1 |
| hook(worker 發) | FAIL:worker 死、master respawn、ping 200 | 死透:容器 exited、ping 000 |
scheduler 路徑本來就會死,因為它跑在 master 自己身上,os._exit(1) 就是 PID 1 退出。只有 worker→master 這條跨進程的訊號是斷的。
註:C-1 改用容器 harness 而非「188 源碼環境+臨時改 SPOT_CHECK_INTERVAL_SECONDS」,理由是源碼模式在 shell 下 pgid != 1,反而測不到本 bug 的關鍵條件(PID 1);harness 更貼近真相,也不佔 188、不需臨時改寫死參數。