If you are running a Windows message loop from Python (for example via ctypes) and notice that pressing Ctrl+C does not terminate the program immediately, you are not imagining things. In some setups, the process will only exit after the next Windows message arrives—such as a battery status update (WM_POWERBROADCAST)—which can make the script appear “stuck.”
This behavior has a clear cause: GetMessageW() can block the thread completely, and Python’s KeyboardInterrupt is typically raised only when control returns to the Python interpreter.
This article explains the mechanism and provides two field-ready mitigation patterns. The recommended approach is Fix A, which is immediate and robust.
Root Cause: GetMessageW() Fully Blocks the Thread Until a Message Arrives
The underlying issue is that GetMessageW() blocks the calling thread until a message is placed into the thread’s message queue.
On Windows, Python’s Ctrl+C handling (SIGINT → KeyboardInterrupt) is, in practice, delivered as an exception when the interpreter regains execution—i.e., when Python gets control back.
So if your code is sitting inside GetMessageW() and no message arrives, then:
-
GetMessageW()does not return, - Python does not regain control,
-
KeyboardInterruptis not raised, - and your
except KeyboardInterrupt:block never runs.
That is exactly why the program may only terminate once “the next message” (battery change, timer tick, etc.) occurs.
Two Practical Fixes (Recommended: Fix A)
Below are two pragmatic countermeasures:
-
Fix A (Recommended): Convert Ctrl+C into a Windows event and wait on both the event and the message queue using
MsgWaitForMultipleObjectsEx(). This gives immediate shutdown. -
Fix B (Simpler): Use
WM_TIMERto periodically “wake”GetMessageW()so Ctrl+C is processed on the next tick. Minimal changes, but not immediate.
Fix A (Recommended): Turn Ctrl+C Into an Event and Wait with MsgWaitForMultipleObjectsEx() (Immediate Exit)
High-level design
- Install a Windows Console Control Handler to catch Ctrl+C (and related console events).
- Signal a manual-reset event created via
CreateEventW()when Ctrl+C is received. - Replace the blocking
GetMessageW()loop withMsgWaitForMultipleObjectsEx()to wait for either:
- (1) the stop event, or
- (2) input in the message queue
This eliminates the “blocked forever in GetMessageW()” failure mode. When Ctrl+C is pressed, the event is set and the wait returns immediately, allowing you to exit the loop deterministically.
Additional constants and API prototypes (add near your “Function prototypes” section)
# Immediate Ctrl+C shutdown support
PM_REMOVE = 0x0001
QS_ALLINPUT = 0x04FF
MWMO_INPUTAVAILABLE = 0x0004
WAIT_OBJECT_0 = 0x00000000
WAIT_FAILED = 0xFFFFFFFF
INFINITE = 0xFFFFFFFF
CTRL_C_EVENT = 0
CTRL_BREAK_EVENT = 1
CTRL_CLOSE_EVENT = 2
CTRL_LOGOFF_EVENT = 5
CTRL_SHUTDOWN_EVENT = 6
# kernel32: event + console handler
kernel32.CreateEventW.argtypes = [wintypes.LPVOID, wintypes.BOOL, wintypes.BOOL, wintypes.LPCWSTR]
kernel32.CreateEventW.restype = wintypes.HANDLE
kernel32.SetEvent.argtypes = [wintypes.HANDLE]
kernel32.SetEvent.restype = wintypes.BOOL
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
kernel32.CloseHandle.restype = wintypes.BOOL
ConsoleCtrlHandler = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.DWORD)
kernel32.SetConsoleCtrlHandler.argtypes = [ConsoleCtrlHandler, wintypes.BOOL]
kernel32.SetConsoleCtrlHandler.restype = wintypes.BOOL
# user32: wait + peek
user32.MsgWaitForMultipleObjectsEx.argtypes = [
wintypes.DWORD, ctypes.POINTER(wintypes.HANDLE), wintypes.DWORD,
wintypes.DWORD, wintypes.DWORD
]
user32.MsgWaitForMultipleObjectsEx.restype = wintypes.DWORD
user32.PeekMessageW.argtypes = [ctypes.POINTER(MSG), wintypes.HWND, wintypes.UINT, wintypes.UINT, wintypes.UINT]
user32.PeekMessageW.restype = wintypes.BOOL
Replace the message-loop section in main() (swap out your # 4) message loop)
Replace your current GetMessageW() loop with the following (the rest of your script can typically remain unchanged):
print("Listening for battery percentage changes. Press Ctrl+C to exit.")
# - Prepare Ctrl+C stop event -
stop_event = kernel32.CreateEventW(None, True, False, None)
if not stop_event:
raise ctypes.WinError(ctypes.get_last_error())
ctrl_handler = None
try:
@ConsoleCtrlHandler
def ctrl_handler(ctrl_type):
if ctrl_type in (
CTRL_C_EVENT, CTRL_BREAK_EVENT, CTRL_CLOSE_EVENT,
CTRL_LOGOFF_EVENT, CTRL_SHUTDOWN_EVENT
):
# Signal stop event (immediately breaks the wait)
kernel32.SetEvent(stop_event)
# True: mark as handled and prevent Python's default Ctrl+C handling
return True
return False
if not kernel32.SetConsoleCtrlHandler(ctrl_handler, True):
raise ctypes.WinError(ctypes.get_last_error())
handles = (wintypes.HANDLE * 1)(stop_event)
# - Wait for either messages or stop_event -
msg = MSG()
while True:
r = user32.MsgWaitForMultipleObjectsEx(
1, handles, INFINITE, QS_ALLINPUT, MWMO_INPUTAVAILABLE
)
if r == WAIT_OBJECT_0:
# stop_event signaled -> exit immediately
break
if r == WAIT_OBJECT_0 + 1:
# Messages available -> drain the queue
while user32.PeekMessageW(ctypes.byref(msg), None, 0, 0, PM_REMOVE):
if msg.message == WM_QUIT:
return 0
user32.TranslateMessage(ctypes.byref(msg))
user32.DispatchMessageW(ctypes.byref(msg))
continue
if r == WAIT_FAILED:
raise ctypes.WinError(ctypes.get_last_error())
return 0
finally:
# Unregister console handler and close event handle
if ctrl_handler:
kernel32.SetConsoleCtrlHandler(ctrl_handler, False)
if stop_event:
kernel32.CloseHandle(stop_event)
Why this works (key points)
- Ctrl+C sets a Windows event immediately.
-
MsgWaitForMultipleObjectsEx()returns as soon as either:- the stop event is signaled, or
- there are messages in the queue
-
You no longer rely on Python’s
KeyboardInterruptbeing raised at the “right time.” -
This removes the structural dependency on “the next Windows message” to regain control.
In other words: the message loop becomes interruptible in a first-class Windows-native way.
Fix B (Simple): Use WM_TIMER to Periodically Wake GetMessageW() (Minimal Changes)
If you want minimal code churn, another approach is to ensure GetMessageW() returns regularly by generating a periodic message.
Using SetTimer() to emit WM_TIMER at a fixed interval ensures that:
-
GetMessageW()will return at least every N milliseconds, - and after you press Ctrl+C, Python will regain control on the next timer tick.
This allows the script to exit, but with a delay bounded by the timer interval. It is an effective “good enough” mitigation for some environments, but it is not truly immediate and is conceptually less clean than Fix A.
