iT邦幫忙

2026 iThome 鐵人賽

DAY 12
0
Software Development

Windows 桌面軟體 CI 實戰:從系統工具開發到 Zero-RDP 自動化測試錄影系列 第 12

Day 12:從 ctypes.windll 到私有 Handle:避免全域污染的 Win32 API 呼叫守則

  • 分享至 

  • xImage
  •  

今天這一篇跟桌面測試沒有直接關係,但適用範圍比這個系列裡任何一篇都廣: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 物件(byrefPOINTERc_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_INPUTPOINTER(我的 INPUT)LP_InputPOINTER(它的 Input),兩個不同的 Python 類別物件(若它用 byref() 傳,訊息會是 instead of pointer to Input,同一件事)。ctypes 驗證指標看的是型別身分,不是記憶體佈局,兩個描述同樣位元組的結構,只因為不是同一個類別就被拒絕。

而最麻煩的地方是:這個例外從那個函式庫裡面拋出來

使用者看到的是「某某套件壞了」。那個套件的維護者拿到 issue,去看自己的程式碼,一切正常,本機也重現不出來——因為要重現得先 import 我的套件。這是一個從外部注入、卻在內部爆炸的 bug。


修法:拿自己的 handle

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=True

use_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,確認不會拿到 ArgumentErrornInputs=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 裡的說明。


後記:私有 handle 只隔離別人,隔離不了自己

修完兩天後,我在同一個專案裡踩了同一顆釘子——而且這次 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 只設 restypeargtypes 留空。 lParam 是多型的:WM_GETTEXT 要指標、SCI_* 要整數,宣告任何一種都會擋掉另一種。restype 要設是因為預設的 c_int 只有 32 位元,而 SendMessage 有些訊息回傳的是指標或 handle(WM_GETFONTHFONT、Scintilla 的 SCI_GETDIRECTPOINTER 回函式指標),在 64 位元上會被截掉高半部。理由寫進了註解,下一個想「順手補上 argtypes」的人會先讀到它。

救我的不是記憶,是那 10 個紅燈——它們來自一個完全無關的檔案,正是正文說的「例外從別人的函式庫裡拋出來」的形狀,只是這次「別人」是我自己。

把教訓寫下來跟真的用上,是兩件不同的事。 前者需要一小時,後者需要在對的那一刻想起來。

小結

  • ctypes.windll.xxx處理程序全域的快取,函式物件是所有人共用的
  • 在上面設定 argtypes 會洩漏給每一個 ctypes 使用者
  • ctypes 驗證指標看型別身分不看佈局,所以別人那個位元組相同的結構會被拒絕
  • 而且例外從別人的函式庫裡拋出來,維護者無從查起
  • 修法:ctypes.WinDLL("user32", use_last_error=True),順便拿到可靠的錯誤碼
  • 寫一個「扮演受害者」的測試來防止退化
  • 私有 handle 只隔離別的套件;模組匯出的 handle 對所有 importer 仍是同一個物件,argtypes 只在定義它的模組設
  • 多型參數(SendMessage 的 lParam)不設 argtypes,只設 restype

明天回到輸入法:為什麼 send_physical_keys("hello") 在我的機器上打出了一個空字串,而這完全是正確行為。


上一篇
Day 11:「按一個鍵」有三種做法,而它們送到不同的地方
下一篇
Day 13:在 CI 全綠,到中文環境全滅——實體按鍵與組字緩衝區
系列文
Windows 桌面軟體 CI 實戰:從系統工具開發到 Zero-RDP 自動化測試錄影24
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言