How to handle a WM_ENDSESSION in tkinter?

Question:

I’m having trouble figuring out how to receive the WM_ENDSESSION window manager message in Tkinter, that is supposed to be sent to the top-level window during system shutdown. I am aware of everything that comes with trying to run extra code during shutdown, however in my case it’ll only be a simple file flush down to disk and close, so that my program has a chance to save it’s state.

My simple test code:

import tkinter as tk

def on_close(*args):
    print("User closed the window")

# I want this to run during shutdown
def on_shutdown(*args):
    print("User is shutting down their PC")

root = tk.Tk()
root.protocol("WM_DELETE_WINDOW", on_close)
root.mainloop()

A code example, or at least a pointer towards which functions or methods to use, would be appreciated.

Asked By: DevilXD

||

Answers:

I would suggest using a message spying too like "Spy++" (spyxx.exe) to check which messages are actually sent to your window.

In some places it is suggested that wireshark can also do this, but I have not found concrete evidence for that.

Answered By: Roland Smith

Tk gives your program "a chance to save it’s state" with the aptly named WM_SAVE_YOURSELF protocol. It is hooked up to WM_QUERYENDSESSION on Windows. The return value seems to be ignored so you cannot prevent WM_ENDSESSION from being dispatched, and therefore it shouldn’t be used for long blocking work, so it really depends on how much you have to write if things will get weird or interrupted.

Hook up your callback with root.protocol("WM_SAVE_YOURSELF", on_close) and let me know if that works!

Answered By: Richard Sheridan

As you, I found out (with respect for the issue) tk.Tk().protocol is pretty useless and your desired behavior is a wish since 1999. In the link is a patch you could try out.

I think the easiest way to go for you will be SetConsoleCtrlHandler:

This function provides a similar notification for console application
and services that WM_QUERYENDSESSION provides for graphical
applications with a message pump. You could also use this function
from a graphical application, but there is no guarantee it would
arrive before the notification from WM_QUERYENDSESSION.

You could find an exampel on StackOverflow or go with Pywin32. The disadvantage over the message approach is that you can’t delay the shutdown further more. But the User would have this option anyway, after the timeout of 5 seconds is expired and you would have with SetConsoleHandler the same amount of time.

A word of caution, flushing a bigger amount of data is explicitly discouraged:

Avoid disk flushes, for example, those initiated through calling
FlushFileBuffers API. Flushing causes the disk stack to delete its
caches and is supposed to force the hard drive to write out data in
its RAM buffers. Typically, this operation is very costly and does not
guarantee data consistency since the hard drives often ignore the
request.

It is recommended that applications save their data and state
frequently; for example, automatically save data between save
operations

In addition there is an example with tkinter and pywin32 that handles WM_ENDSESSION, if you intrested.

Answered By: Thingamabobs

After many struggles of trying to find information online and failing to get my code to work, this is what eventually ended up working for me in the end. It’s based on the https://stackoverflow.com/a/58622778/13629335 answer, suggested by @furas and @Thingamabobs. I’ve added helpful comments explaining what each section does. Feel free to adjust it up to your own needs.

import sys
import time
import ctypes
import logging
import tkinter as tk
from tkinter import ttk

import win32con
import win32gui
import win32api


# NOTE: this is used in the root.mainloop() replacement at the bottom.
exit_requested = False
def quit():
    global exit_requested
    exit_requested = True


# Received when the user tries to close the window via X in the top right corner.
def close_window(*args, **kwargs):
    logging.info("WM_CLOSE received")
    quit()
    # 0 tells Windows the message has been handled
    return 0


# Received when the system is shutting down.
def end_session(*args, **kwargs):
    logging.info("WM_ENDSESSION received")
    quit()
    # Returning immediately lets Windows proceed with the shutdown.
    # You can run some shutdown code here, but there's a 5 seconds maximum timeout,
    # before your application is killed by Windows.
    return 0


# Received when the system is about to shutdown, but the user can
# cancel this action. Return 0 to tell the system to wait until
# the application exits first. No timeout.
def query_end_session(*args, **kwargs):
    logging.info("WM_QUERYENDSESSION received")
    quit()
    # 1 means you're ready to exit, and you'll receive a WM_ENDSESSION immediately afterwards.
    # 0 tells Windows to wait before proceeding with the shutdown.
    return 0


# Simple logging setup to catch all logging messages into a file.
file_handler = logging.FileHandler("shutdown_test.log", encoding="utf8")
file_handler.setFormatter(logging.Formatter("%(asctime)s: %(message)s"))
root_logger = logging.getLogger()
root_logger.setLevel(logging.DEBUG)
root_logger.addHandler(file_handler)
logging.info("starting shutdown test")

# Start of your application code
root = tk.Tk()
root.title("Shutdown test")
main_frame = ttk.Frame(root, padding=20)
main_frame.grid(column=0, row=0)
ttk.Label(
    main_frame, text="Shutdown test in progress...", padding=50
).grid(column=0, row=0, sticky="nsew")
# End of your application code

# This is crucial - a root.update() after all application setup is done,
# is very needed here, otherwise Tk won't properly set itself up internally,
# leading to not being able to catch any messages later.
root.update()

# NOTE: These two lines below can be used for basic message handling instead.
# Return value from WM_SAVE_YOURSELF is ignored, so you're expected to
# finish all of the closing sequence before returning. Note that Windows will wait
# for you up to 5 seconds, and then proceed with the shutdown anyway.

# root.protocol("WM_DELETE_WINDOW", close_window)
# root.protocol("WM_SAVE_YOURSELF", query_end_session)

root_handle = int(root.wm_frame(), 16)
message_map = {
    win32con.WM_CLOSE: close_window,
    win32con.WM_ENDSESSION: end_session,
    win32con.WM_QUERYENDSESSION: query_end_session,
}


def wnd_proc(hwnd, msg, w_param, l_param):
    """
    This function serves as a message processor for all messages sent to your
    application by Windows.
    """
    if msg == win32con.WM_DESTROY:
        win32api.SetWindowLong(root_handle, win32con.GWL_WNDPROC, old_wnd_proc)
    if msg in message_map:
        return message_map[msg](w_param, l_param)
    return win32gui.CallWindowProc(old_wnd_proc, hwnd, msg, w_param, l_param)


# This hooks up the wnd_proc function as the message processor for the root window.
old_wnd_proc = win32gui.SetWindowLong(root_handle, win32con.GWL_WNDPROC, wnd_proc)
if old_wnd_proc == 0:
    raise NameError("wndProc override failed!")

# This works together with WM_QUERYENDSESSION to provide feedback to the user
# in terms of what's preventing the shutdown from proceeding.
# NOTE: It's sort-of optional. If you don't include it, Windows will use
# a generic message instead. However, your application can fail to receive
# a WM_QUERYENDSESSION if it's window is minimized (via iconify/withdraw)
# when the message happens - if you also need to be able to handle that case,
# then you'll need it.
retval = ctypes.windll.user32.ShutdownBlockReasonCreate(
    root_handle, ctypes.c_wchar_p("I'm still saving data!")
)
if retval == 0:
    raise NameError("shutdownBlockReasonCreate failed!")

# NOTE: this replaces root.mainloop() to allow for a loop exit
# without closing any windows - root.quit() apparently does so.
while not exit_requested:
    root.update()
    time.sleep(0.05)

# Your shutdown sequence goes here.
logging.info("shutdown start")
time.sleep(10)
logging.info("shutdown finished")
root.destroy()
sys.exit(0)
Answered By: DevilXD