今天這一篇跟桌面測試沒有直接關係,但適用範圍比這個系列裡任何一篇都廣:在 ctypes.windll 上設定 argtypes,改到的是同一個處理程序裡每一個 ctypes 使用者。
這是標準教學都會教的寫法,我自己也一路這樣寫,直到有人把 wintegrate 和 pydirectinput 裝在同一個環境裡、回報 pydirectinput 壞了,才發現問題在我這邊。
用 ctypes 呼叫 Win32 API,標準教學會告訴你這樣寫:
import ctypes
from ctypes import wintypes
user32 = ctypes.windll.user32
user32.SendInput.argtypes = [wintypes.UINT, ctypes.POINTER(INPUT), ctypes.c_int]
user32.SendInput.restype = wintypes.UINT
設定 argtypes 是好習慣:它讓 ctypes 在呼叫前檢查型別,也決定每個參數的寬度。不設定時,ctypes 只能靠猜:傳 ctypes 物件(byref、POINTER、c_wchar_p)沒問題,但一個以 Python int 傳入的位址或 HWND 會被當成 32 位元的 C int,在 64 位元上高半部就被截掉了;回傳值同樣預設 c_int,指標或高位打包的回傳值也會被截。結果是隨機的崩潰或錯值。
問題不在設定 argtypes,在你設定在哪個物件上。
ctypes.windll 是一個快取ctypes.windll.user32 不是每次都建立新物件。ctypes.windll 是一個 LibraryLoader,它的 __getattr__ 第一次載入 user32 之後就 setattr 存回自己身上;CDLL.__getattr__ 對函式做同一件事,第一次取 SendInput 之後就把那個函式物件存回 DLL 物件上。兩層都是快取,而 ctypes.windll 本身是模組層級的單一實例,所以同一個處理程序裡所有人拿到的是同一個 user32、同一個 SendInput 函式物件。你寫的這一行:
user32.SendInput.argtypes = [..., ctypes.POINTER(INPUT), ...]
改的是全域狀態。任何其他套件、任何其他模組,只要它也用 ctypes.windll.user32.SendInput,看到的就是你設定的那組 argtypes。
假設另一個套件也要呼叫 SendInput。它很盡責,也定義了自己的 INPUT 結構——欄位一樣、記憶體佈局完全相同:
# 另一個函式庫裡
class Input(ctypes.Structure):
_fields_ = [("type", wintypes.DWORD), ("union", _InputUnion)]
user32.SendInput(1, ctypes.pointer(my_input), ctypes.sizeof(Input))
它會拿到這個錯誤:
ctypes.ArgumentError: argument 2: TypeError: expected LP_INPUT instance instead of LP_Input
LP_INPUT 是 POINTER(我的 INPUT),LP_Input 是 POINTER(它的 Input),兩個不同的 Python 類別物件(若它用 byref() 傳,訊息會是 instead of pointer to Input,同一件事)。ctypes 驗證指標看的是型別身分,不是記憶體佈局,兩個描述同樣位元組的結構,只因為不是同一個類別就被拒絕。
而最麻煩的地方是:這個例外從那個函式庫裡面拋出來。
使用者看到的是「某某套件壞了」。那個套件的維護者拿到 issue,去看自己的程式碼,一切正常,本機也重現不出來——因為要重現得先 import 我的套件。這是一個從外部注入、卻在內部爆炸的 bug。
if sys.platform == "win32":
user32 = ctypes.WinDLL("user32", use_last_error=True)
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
imm32 = ctypes.WinDLL("imm32", use_last_error=True)
gdi32 = ctypes.WinDLL("gdi32", use_last_error=True)
ole32 = ctypes.WinDLL("ole32", use_last_error=True)
ctypes.WinDLL(...) 直接建構,不走 ctypes.windll 的快取,你拿到的是自己的私有 handle。在上面設定 argtypes 只影響透過這個 handle 發出的呼叫。
use_last_error=Trueuse_last_error=True 讓 ctypes 在每次呼叫前後把系統的 last-error 與一份 ctypes 私有的執行緒區域副本交換,呼叫剛結束時的 GetLastError() 值因此被保存下來,之後用 ctypes.get_last_error() 取回。
為什麼要這個?因為 GetLastError 是執行緒層級的狀態,而任何一次中間的 Win32 呼叫都會覆蓋它——包括 ctypes 自己內部可能做的事。use_last_error 讓 ctypes 在呼叫返回的當下就抓住那個值,你之後讀到的是正確的錯誤碼,而不是被覆蓋過的。
沒有它,你的錯誤處理會偶爾報告錯誤的原因,而那比沒有錯誤處理更糟。
這種 bug 的特性是「加回去很容易,發現很難」。某天有人為了方便寫了一行 ctypes.windll.user32.XXX,程式照樣能動,測試也全綠——因為在測試環境裡沒有第二個套件來揭穿它。
所以要有測試專門盯著這件事:
def test_handles_are_private_instances():
"""Our DLL handles must not be the shared ctypes.windll ones."""
assert interop.user32 is not ctypes.windll.user32
@pytest.mark.parametrize("func", ["SendInput", "MapVirtualKeyW",
"VkKeyScanW", "GetKeyboardLayout"])
def test_pinned_argtypes_do_not_leak_to_the_shared_handle(func):
"""Pinning argtypes on our handle must leave the shared one untouched."""
shared = getattr(ctypes.windll.user32, func)
assert getattr(shared, "argtypes", None) is None
def test_another_library_can_still_call_sendinput_with_its_own_struct():
"""The exact failure this design prevents: a byte-identical struct from
somewhere else must not be rejected."""
# TheirKeybdInput / TheirUnion 的欄位與 Win32 定義一致,此處省略
class TheirInput(ctypes.Structure):
_fields_ = [("type", wintypes.DWORD), ("u", TheirUnion)]
theirs = TheirInput()
# nInputs=0: nothing is injected, but ctypes still converts every argument,
# which is where the leaked argtypes used to raise.
ctypes.windll.user32.SendInput(0, ctypes.pointer(theirs), ctypes.sizeof(theirs))
最後那個測試扮演受害者:它定義一個自己的 INPUT 結構,透過共享 handle 呼叫 SendInput,確認不會拿到 ArgumentError。nInputs=0 讓 Windows 什麼都不注入,但 ctypes 仍會轉換每一個參數,洩漏的 argtypes 就是在那一步拋例外的。
這是我認為回歸測試最好的形式:不測「我的程式碼對不對」,而是重現「別人會怎麼被我弄壞」。
專案的 docs/pitfalls.md 裡我把這句話寫在最後,因為它比 wintegrate 本身重要:
This one is worth knowing regardless of this library: any package that pins
argtypes on `ctypes.windll` is a hazard to everything else in the process.
如果你在寫任何用 ctypes 呼叫 Win32 的 Python 套件——不管是做輸入模擬、視窗管理、系統資訊、還是印表機控制——用 ctypes.WinDLL() 拿自己的 handle 就好。
這是一行的差別,而它決定了你的套件能不能跟別人和平共存。順帶一提,如果你在別的套件上遇到了本文那個 LP_INPUT 錯誤,這也是一份可以直接附在 issue 裡的說明。
修完兩天後,我在同一個專案裡踩了同一顆釘子——而且這次 ctypes.windll 完全沒有出場。
為了驅動 WinMerge 的 Options 對話框,我在測試模組頂端宣告了幾個 prototype:
from wintegrate.interop import user32 # ← wintegrate 自己的私有 handle
user32.SendMessageW.argtypes = [wintypes.HWND, wintypes.UINT,
wintypes.WPARAM, wintypes.LPARAM]
看起來無害。SendMessage 的 lParam 宣告成 LPARAM(一個整數型別)也很合理。
然後 10 個 Scintilla 測試炸了,在一個這支測試從未碰過的檔案裡:
ctypes.ArgumentError: argument 4: TypeError:
'_ctypes.CArgObject' object cannot be interpreted as an integer
get_value() 的 WM_GETTEXT 備援會把緩衝區當 lParam 傳。而我剛剛宣告了那個參數是整數,所以整個函式庫的文字讀取都壞了——只因為 pytest 收集測試時 import 了我那個模組。
這裡「共享」的意思跟正文不同,值得分開講。interop.user32 已經是私有 handle,ctypes.windll 的使用者確實安全了;但任何從模組匯出的 handle,對它的每一個 importer 仍然是同一個物件。私有 handle 畫出的邊界是「我的套件」對「別的套件」,套件內部的模組彼此之間沒有邊界。所以守則的完整版是兩條:對外拿私有 handle;對內,argtypes 只在定義 handle 的那個模組設,其他模組只呼叫、不宣告。
實際的修法也是兩件事:
一、測試模組改拿自己的 ctypes.WinDLL("user32"),它要什麼 prototype 自己設,不碰函式庫的 handle。
二、函式庫的 SendMessageW 只設 restype,argtypes 留空。 lParam 是多型的:WM_GETTEXT 要指標、SCI_* 要整數,宣告任何一種都會擋掉另一種。restype 要設是因為預設的 c_int 只有 32 位元,而 SendMessage 有些訊息回傳的是指標或 handle(WM_GETFONT 回 HFONT、Scintilla 的 SCI_GETDIRECTPOINTER 回函式指標),在 64 位元上會被截掉高半部。理由寫進了註解,下一個想「順手補上 argtypes」的人會先讀到它。
救我的不是記憶,是那 10 個紅燈——它們來自一個完全無關的檔案,正是正文說的「例外從別人的函式庫裡拋出來」的形狀,只是這次「別人」是我自己。
把教訓寫下來跟真的用上,是兩件不同的事。 前者需要一小時,後者需要在對的那一刻想起來。
ctypes.windll.xxx 是處理程序全域的快取,函式物件是所有人共用的argtypes 會洩漏給每一個 ctypes 使用者ctypes.WinDLL("user32", use_last_error=True),順便拿到可靠的錯誤碼argtypes 只在定義它的模組設SendMessage 的 lParam)不設 argtypes,只設 restype
明天回到輸入法:為什麼 send_physical_keys("hello") 在我的機器上打出了一個空字串,而這完全是正確行為。