【任務內容】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 第一輪回報。

2026-08-16 修復完成(CM-1241):根因與卡上假設不同,已修+驗

commit 7cc7989e(BE feature/FR-064,未 push)。

🔴 根因更正:master 不是「收到 SIGTERM 卻沒死」,而是根本沒收到

卡上(與 _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/statusSigCgt bit15 已設,正確證明了「master SIGTERM handler」,但被讀成「master 收到了訊號並用 handler 處理掉」——有能力接收 ≠ 實際收到

連帶結論:卡上的候選修法「SIGTERM 改 SIGKILL(不可 catch 必死)」對 PID 1 無效,改下去只會從「不死」變成「還是不死且更難查」。

C-1 結果:scheduler 路徑不同病

用容器化 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、不需臨時改寫死參數。

修法:依承載分流送訊號