一台 CI runner 在你的測試開始之前,並不是空的。今天講怎麼清,以及為了清它,我做了一個關於相依套件的決定。
windows-latest 的映像裝了非常多東西,而其中一些會在背景冒出視窗:
任何一個都可能搶走焦點。而一旦焦點被搶走,你的 send_keys 就打到別人身上去了——而且不會有任何錯誤,因為從 Windows 的角度那是完全合法的操作。
所以測試開始前要做兩件事:
def sanitize_ci_runner_environment():
"""
Cleans up known GitHub Actions CI runner hazards:
1. Terminates background WSL, Windows Terminal, and Edge popups without
touching current runner PID or parents.
2. Minimizes background windows (excluding current test runner process).
"""
終止已知的干擾者,以及把其餘的背景視窗最小化。第二件事比第一件溫和——你不知道那是什麼,但至少讓它離開前景。
清場最大的風險是把自己清掉。
你的 pytest 處理程序是誰啟動的?GitHub Actions 的 runner。runner 又是誰啟動的?某個服務。如果你的清理邏輯看到一個「背景處理程序」就殺,很有機會殺到自己的祖先——然後整個 job 直接消失,連錯誤訊息都不會有。
所以要先建立一份豁免名單:
excluded_pids = {os.getpid()}
try:
excluded_pids |= get_ancestor_pids()
except Exception:
pass
get_ancestor_pids() 從自己往上走處理程序樹,把所有祖先都加進豁免。
在寫任何會終止處理程序的程式碼之前,先寫「哪些絕對不能碰」。 這個順序很重要——先寫破壞、之後再補防護,中間那段時間你會在 CI 上遇到非常難查的失敗。
而這份名單我寫錯過一次,錯得很貴。下面那一段是這篇文章最重要的部分。
0.5.12 的清場長這樣:
Get-Process -Name wsl,wslhost,WindowsTerminal,msedge,msedgewebview2 |
Where-Object { $_.Id -notin @(<自己 + 所有祖先>) } | Stop-Process -Force
自己和祖先都豁免了。然後它在 GitHub 的 runner 上殺掉了自己——三輪 CI,每一輪 harness 一進 Session.__enter__ 就死,log 最後一行是一段落在 import av 的 KeyboardInterrupt,step 卡在 in_progress,artifact 目錄是空的。
自動化的除錯流程先後給了兩個解釋:PyAV 的 ARM64 wheel 壞了;以及某種要等 12 分鐘才會觸發的 hang。兩個都是從文字推出來的。第二個尤其只會出自一個不看畫面的程式:它讀的是 CI 頁面上 in_progress 旁邊那個 12 分鐘,把欄位當成了現象。任何一個看著螢幕的人,幾秒內就會看到什麼都沒在跑——這題一般人不會錯,錯的是流程。實測是 0.27 秒內死掉,而 step 看起來還活著是因為孤兒握著 stdio 的 pipe,不是有行程在跑。
真正的機制要丟一支 probe 到 runner 上才量得到:
GetConsoleWindow() = 0
GetConsoleProcessList() = 3 -> python.exe, pwsh.exe, Runner.Worker.exe
Windows 11 上 Windows Terminal 是預設的 console host。它不是任何人的祖先——它透過 DelegationConsole 交棒進來,量到的 console 視窗擁有者是 OpenConsole.exe,祖先鏈是 svchost → wininit → services。但它 host 的那個 console 上 attach 著三個行程:我的 harness、跑這個 step 的 pwsh、以及回報這個 job 的 Runner.Worker.exe。
殺掉 host,console 消失,每個 attached 的行程收到 CTRL_C。Python 這邊那是 KeyboardInterrupt,落在當下正在跑的那一行——剛好是 import av,所以看起來像 PyAV 的錯。Runner.Worker 一起死,所以沒有人回報 step、沒有人接 cancel,只有 Force cancel 有用,而它會把 VM 連 artifacts 一起銷毀。
「不能碰的東西」不只有你的祖先。還有所有跟你共用同一個 console 的行程,以及它們的祖先。 而那些關係不在處理程序樹上,要另外問。
「有 console 就不要殺 terminal host」聽起來對。0.5.13 用 GetConsoleWindow() 問「我有 console 嗎」——在 runner 上它回 0。ConPTY 底下的 console 沒有視窗,守衛回答「沒有 console」,清場照殺。
這一版是沒量就修的。一個人坐在那台 runner 前面,第一件事會是問機器本身:我的 console 上掛著誰?流程沒有這一步,直接從「聽起來對」出發發了版。量了之後才知道問錯問題:GetConsoleWindow() 問的是「這個 console 有沒有視窗」,而該問的是「有誰 attach 在這個 console 上」——GetConsoleProcessList()。0.5.14 改成它。一個 release cycle,換一個教訓:
先丟 probe 量,再修。 一個只在 CI 上發生的問題,在你的機器上「合理」的假設沒有任何證據價值。
下一個直覺是「把 WindowsTerminal 從清單拿掉」。這是錯的規則形狀:在一個沒有 attach 到它的 run 裡,它是普通的目標;把它按名字排除,只是把下一個事故換成別的名字。
0.5.15 的做法是把保護寫成對這個行程量出來的事實:
def _relations(parents, console_peers, self_pid) -> dict[int, str]:
# self、self 的祖先、共用這個 console 的行程、以及它們的祖先
# 每一個 pid 對應一個「為什麼不能碰」的理由
protected_pids() 回傳 {pid: 關係},三個量測(父子表、console peers、自己的 pid)餵進一個純函式,規則的部分離線就能測。而 primitive 本身不變:Window.close(force=True) 會殺就殺。判斷放在上面那一層——AppHandle.close() 發現目標在保護集合裡,就降級成 WM_CLOSE,記下是哪個關係擋的;量不到關係就 fail closed。
相關原始碼:
_relations/protected_pids/has_console與AppHandle.close
一個會安靜拒絕的 primitive 比一個會殺的更糟——你以為清了,它沒清,而且沒說。拒絕要在有理由、而且會把理由記下來的那一層做。
Windows Terminal 還是會搶前景。既然不能殺它的行程,就藏它的視窗:ShowWindow(hwnd, SW_HIDE)。行程不動、console 不動、測試看不到它。這條也適用於 runner 自己的 conhost——那個標題是 hosted-compute-agent 路徑的黑色大視窗,在每一支 arm64 錄影的背景裡。
一個人動手藏一個視窗,會看一眼它是不是真的不見了,做完事會把它放回來。清場裡那三個 ShowWindow(hwnd, SW_HIDE) 兩件都沒做:藏了哪些沒記、藏成沒藏成沒看、結束時沒還。而「藏成沒藏成」不是多慮:ShowWindow 的回傳值是之前的可見狀態,不是成功與否,同一天量到 Start 選單的 CoreWindow 對 SW_HIDE 完全不動——連藏八次,前景一次都沒變。只有事後再問一次 IsWindowVisible 才分得出來。
所以現在每一次藏都是一筆 InterventionResult:想要的狀態、0.3 秒後再量到的狀態、兩者是否一致;前景在整輪藏之前和之後各量一次。這些記在 kill_plan.json 的 interventions 和時間軸的 hide_result 裡。Session 結束時把這個 session 藏的、現在還藏著、視窗還在的那些用 SW_SHOWNA 放回來,別的不碰——不是它藏的不還,已經被別人叫出來的不重複叫,已經不存在的不碰(restore_targets 是純函式,離線就能測)——然後同樣再量一次,記成 restore_result。
VM 上的正控:一個標題符合規則的真視窗,藏完再量 IsWindowVisible 是 False、還回來再量是 True;走一次完整的 Session,視窗在 session 裡是藏著的、__exit__ 之後可見,kill_plan.json 裡是一筆 hide 一筆 restore,都 verified: true。CI 上的 arm64 job 則量到 interventions: []、前景 Progman -> Progman:Day 28 那一步已經把桌面清乾淨,清場沒有東西可藏——這句話現在是量出來的,不是假設的。
事故最貴的部分不是修,是三輪 CI 沒有留下任何證據。一個人看著螢幕,會看到終端機視窗在清場的那一刻消失,答案當場就有;一個沒人看的流程,只能在動手前自己把要做的事寫下來。所以清場現在先建一份計畫再動手:一次 process snapshot,清單上的每個行程標成 kill 或 spare(帶著讓它倖免的關係),寫進 kill_plan.json 並 fsync、寫進事件時間軸,然後才逐 pid 用 TerminateProcess 結束,記下每一筆的結果。事件時間軸如果停在 kill_plan 而沒有 kill_result,那就是清場殺了自己,而計畫會說是哪一筆。
還有一個 dry_run(WINTEGRATE_SANITIZE_DRY_RUN=1):計畫照寫,什麼都不殺,step 跑完,artifact 上傳。這是「先丟 probe 量測」包成旗標的樣子。
計畫寫在磁碟上,回答的是「誰被殺了」。但事故裡最誤導人的一行是另一句:traceback 停在 import av 的 KeyboardInterrupt。一個看著螢幕的人不會被它騙——他剛看到終端機在清場那一刻消失,0.27 秒後這個行程跟著死,他會說「是那個 kill」,不會說「是 Ctrl-C」也不會說「PyAV 壞了」。流程沒有眼睛,它只有 traceback,而 traceback 只說行程死的時候剛好站在哪一行,不說是什麼殺的。
所以 Session.__enter__ 現在把危險的那一半(錄影、OOBE、虛擬桌面、清場、普查,每段都有標籤)包在一個 handler 裡。例外進來時做一次重新探測,跟 preflight 比:如果 preflight 時 console 有答(attached、client 清單非空),現在什麼都不答,那這個 KeyboardInterrupt 就是 console host 被銷毀,不是 Ctrl-C。這時候例外被換成 ConsoleHostEndedError,訊息裡寫:死在哪個階段、進入該階段幾秒、這次計畫結束了哪些行程、有哪些 console 同儕會跟著死。它同時繼承 KeyboardInterrupt,所以 pytest 還是中止一次,而不是在一台 console 已經沒了的機器上再冒出幾十個錯誤。
三個約束,每一個都是從錯過的方向來的:
console_host_verdict,離線測遍每個分支:從來沒有 console 的行程做不出裁決;console 還在的真 Ctrl-C 原樣重丟。GetConsoleProcessList,不普查、不截圖、不開子行程,自己包 try/except——命名器裡的任何失敗都不可以蓋掉原本的例外。console_host_ended,帶著同一組事實。上面那個「step 看起來還活著」的機制也修了。launch_and_discover 原本用 subprocess.Popen(cmd) 啟動 app,什麼都沒重導,所以每一個被啟動的 app(和它再生出來的東西)都繼承了 step 的 stdout/stderr handle;harness 死了,孤兒還握著 pipe,runner 就一直等。現在子行程的 stdin 是 DEVNULL,stdout/stderr 在 Session 裡寫到 artifact 目錄的 launched_NN.out / .err,沒有 Session 就丟 DEVNULL——永遠不繼承。順帶把 Day 17 的第六種也補上了:探索逾時的訊息會把子行程印過的東西引出來。
要往上走處理程序樹,最省事的做法是 psutil:
import psutil
p = psutil.Process(os.getpid())
ancestors = {a.pid for a in p.parents()}
psutil 是一個成熟、好用、跨平台的套件,這段程式碼三行就寫完。我一開始就是這樣寫的。
後來把它換成自己實作的 ctypes 版本。這個決定跟 psutil 的品質完全無關,是關於這是一個函式庫。
一個應用程式多裝一個相依套件,成本由開發者自己承擔。一個函式庫多裝一個相依套件,成本轉嫁給每一個使用者:他們的依賴解析多一個節點、他們的供應鏈多一個環節、他們如果已經固定了某個版本可能會衝突。
而我從 psutil 用到的功能是:從一個 PID 往上找父 PID。這在 Windows 上是兩個 API 的事。
所以判準是:我用到了這個套件的多少? 如果是 5%,而那 5% 自己實作要五十行,那對函式庫來說通常划不來。這件事對應用程式的答案會不一樣。
Windows 提供了一個列舉所有處理程序的機制:
CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)
Process32FirstW / Process32NextW # 逐一走訪
每一筆 PROCESSENTRY32W 裡有 th32ProcessID 和 th32ParentProcessID,於是可以建出「子 → 父」的對照表,然後從自己開始往上爬。
搭配 QueryFullProcessImageNameW 可以從 PID 拿到執行檔完整路徑——這就是 Day 18 說的「用執行檔名稱而不是視窗標題」的實作基礎。
實作上有兩個必須加的防護:
一、上界。 往上爬要有次數上限。正常的處理程序樹深度不會超過幾十層,設個上限,超過就停。
二、循環防護。 理論上處理程序樹是一棵樹,不會有環。實務上 PID 會被回收——一個處理程序死了,它的 PID 被新的處理程序拿去用,而你的快照裡如果混到了不同時間點的資料,就可能出現 A 的父是 B、B 的父是 A。
seen = set()
while pid and pid not in seen and len(seen) < MAX_DEPTH:
seen.add(pid)
pid = parent_of.get(pid)
在系統程式裡,「這個結構理論上不會有環」不足以支撐一個無界迴圈。 尤其當這段程式碼的下一步是「終止這些以外的處理程序」時——一個無窮迴圈在這裡是最好的結果,最壞的是走出一條錯的鏈然後殺錯東西。
sanitize_ci_runner_environment() 的第一行是:
attach_to_input_desktop()
這回到 Day 2。如果你的執行緒沒有附加在當下的 Input Desktop 上,你列舉到的視窗就不是螢幕上那些,「最小化背景視窗」這個動作會作用在一組不相干的視窗上。
清場之前,先確認你在打掃的是對的房間。
@property
def should_sanitize_runner(self) -> bool:
if isinstance(self.sanitize_runner, str) and self.sanitize_runner.lower() == "auto":
return env.is_desktop and is_ci()
return bool(self.sanitize_runner)
"auto" = 只在 CI 上做。
這又是那個模式(Day 2、Day 17):一個會終止處理程序、最小化視窗的功能,在拋棄式的 runner 上是必要的清場,在開發者的機器上是災難——它會關掉你正在用的東西。
任何有破壞性的自動行為,預設值都應該是「只在確定沒有人在用這台機器時才做」。
GetConsoleWindow() 在 ConPTY 底下回 0;問「有誰 attach」要用 GetConsoleProcessList()
ShowWindow 回傳的是之前的狀態,不是成功),記成 InterventionResult;走的時候把自己藏的、還藏著、還在的還回去__enter__ 裡要說是誰殺的:console 探針 preflight 有答、現在沒答,就把那個 KeyboardInterrupt 改名為 ConsoleHostEndedError——有證據才改,沒證據原樣重丟attach_to_input_desktop()
明天講這一章的最後一塊,也是這個系列最花力氣的一段:ARM64 不只是另一個架構,它是另一台機器——以及我怎麼在 MacBook 上開一台 Windows 11 ARM64 來重現 CI 現場。