Fix PyAutoGUI FailSafeException triggered on virtual display or headless RDP

Root Cause Diagnosis

PyAutoGUI raises FailSafeException when pyautogui.position() returns (0, 0) at the start of any API call (FAILSAFE = True, default FAILSAFE_POINTS = [(0, 0)]). Virtual and disconnected sessions report that value in two ways. On Xvfb, Xvnc and xrdp, no input device has moved the pointer, so it sits at the origin. On Windows, GetCursorPos fails with ERROR_ACCESS_DENIED when the RDP window is minimized, the session is disconnected, or the workstation is locked, and PyAutoGUI does not check the return value, so it reads the zero-initialized POINT. Because the check runs before each call, moveTo() cannot relocate the cursor either: it raises before moving.

Quick Fix

Linux (Xvfb, xrdp, VNC): park the pointer before the script starts.

xvfb-run -a -s "-screen 0 1920x1080x24" sh -c 'xdotool mousemove 960 540 && python3 script.py'

# Existing display:
DISPLAY=:99 xdotool mousemove 960 540

Windows (RDP): reattach the session to the console before disconnecting. Run in an elevated prompt:

for /f "skip=1 tokens=3" %s in ('query user %USERNAME%') do %windir%\System32\tscon.exe %s /dest:console

Use %%s instead of %s inside a .bat file. The RDP window closes, but the session keeps its desktop and cursor.

Code override (masks the symptom, does not fix it):

import pyautogui
pyautogui.FAILSAFE = False

Step-by-Step Resolution

1. Confirm what position() reports inside the automation’s own session

import pyautogui
print(pyautogui.position(), pyautogui.size())

Run it in the same session, user, and launcher (service, cron, scheduled task) as the failing script. A result of Point(x=0, y=0) confirms the diagnosis.

2. Windows: verify the cursor API failure

import ctypes
from ctypes import wintypes

user32 = ctypes.WinDLL("user32", use_last_error=True)
pt = wintypes.POINT()
ok = user32.GetCursorPos(ctypes.byref(pt))
print(ok, ctypes.get_last_error(), pt.x, pt.y)
# healthy: 1 0 <x> <y>
# broken:  0 5 0 0   (ERROR_ACCESS_DENIED)

3. Windows: keep the session alive with an interactive desktop

See also  Python Requests SSLError: UNEXPECTED_EOF_WHILE_READING on specific HTTPS hosts

Run the automation from a session that stays logged in, not from a service or from Task Scheduler’s “Run whether user is logged on or not” option. Those run in session 0 with no interactive desktop. Use “Run only when user is logged on”.

Set disconnected sessions to never end:

Computer Configuration\Administrative Templates\Windows Components\Remote Desktop Services\Remote Desktop Session Host\Session Time Limits\Set time limit for disconnected sessions → Never

Prevent the lock screen, which also blocks cursor access:

reg add "HKCU\Control Panel\Desktop" /v ScreenSaveActive /t REG_SZ /d 0 /f
reg add "HKCU\Control Panel\Desktop" /v ScreenSaverIsSecure /t REG_SZ /d 0 /f

Computer Configuration\Windows Settings\Security Settings\Local Policies\Security Options\Interactive logon: Machine inactivity limit → 0

4. Windows: automate the console handoff

Save as C:\Scripts\detach.bat and use it instead of closing the RDP window:

@echo off
for /f "skip=1 tokens=3" %%s in ('query user %USERNAME%') do (
  %windir%\System32\tscon.exe %%s /dest:console
)

If it fails with Access is denied, run it as SYSTEM:

PsExec.exe -s cmd /c C:\Scripts\detach.bat

5. Linux: start the virtual display with explicit geometry and depth

Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp &
export DISPLAY=:99
xdotool mousemove 960 540
xdotool getmouselocation

6. Linux xrdp: keep the session alive and reachable

/etc/xrdp/sesman.ini

[Sessions]
KillDisconnected=false
DisconnectedTimeLimit=0
IdleTimeLimit=0
sudo systemctl restart xrdp xrdp-sesman

Launch the script from inside the xrdp session, or export the session’s variables (DISPLAY=:10.0, XAUTHORITY=$HOME/.Xauthority) when starting it from cron or systemd. Disable any screen locker (xscreensaver, light-locker, gnome-screensaver).

7. Harden the script

Unlock the cursor once, verify state, then restore the fail-safe. This works only because FAILSAFE is switched off for the relocating call:

import pyautogui

w, h = pyautogui.size()
if tuple(pyautogui.position()) == (0, 0):
    pyautogui.FAILSAFE = False
    pyautogui.moveTo(w // 2, h // 2)
    pyautogui.FAILSAFE = True

If the fail-safe must stay disabled for unattended runs, add an external kill switch:

import pathlib

STOP = pathlib.Path("/tmp/stop_automation")   # Windows: C:\Scripts\stop_automation

def guard():
    if STOP.exists():
        raise SystemExit("kill-switch file present")

# call guard() between actions

Alternatively, move the trigger to a corner your workflow never uses (PyAutoGUI ≥ 0.9.50; check with hasattr(pyautogui, "FAILSAFE_POINTS")):

w, h = pyautogui.size()
pyautogui.FAILSAFE_POINTS = [(w - 1, h - 1)]

8. Recompute coordinates at runtime

See also  Fixing Selenium target window already closed error during fast alert handling

Never hardcode absolute coordinates. Screen size can differ between the RDP session and the console after tscon. Coordinates outside the screen, including negative offsets, are clamped to the edge and can land on (0, 0).

w, h = pyautogui.size()
x, y = int(w * 0.5), int(h * 0.5)
pyautogui.click(x, y)

Why This Fails for Some Users

  • FAILSAFE = False only hides the symptom: On a disconnected Windows session, screenshot() and locateOnScreen() still fail with OSError: screen grab failed, and input goes to a desktop nobody can see. The script may run to completion with no visible effect.
  • Services and session 0: Windows services and non-interactive scheduled tasks have no interactive desktop, so no PyAutoGUI workaround applies. Use a logged-in session.
  • Wayland (Ubuntu 22.04+, Fedora, RHEL 9 defaults): PyAutoGUI uses X11 via python-xlib. Under XWayland the pointer position is only reported while the cursor is over an X11 window, and XTEST input does not reach native Wayland windows. Switch to an Xorg session by setting WaylandEnable=false under [daemon] in /etc/gdm3/custom.conf (Debian/Ubuntu) or /etc/gdm/custom.conf (Fedora/RHEL), then restart gdm. gnome-remote-desktop headless sessions are Wayland, so use xrdp with Xorg instead.
  • Xvfb default depth: Without an explicit -screen argument, Xvfb uses 1280×1024 at 8-bit color, which breaks image matching. Always pass x24.
  • tscon to console on machines without a monitor or GPU output: The console desktop can come up with no usable display, so you need a dummy HDMI/DisplayPort plug or a virtual display driver. In a VM, use a virtual GPU adapter.
  • Resolution change after tscon: The session adopts the console’s display mode, so cached coordinates and template images from the RDP resolution become invalid. pyautogui.size() at runtime handles this.
  • PyAutoGUI < 0.9.50: There is no FAILSAFE_POINTS, and the check is a hardcoded position() == (0, 0). Only FAILSAFE = False is available. Upgrade with pip install -U pyautogui pyscreeze pymsgbox.
  • Third-party automation inside the same session: A parallel tool (AHK, remote-control agent, RDP wrapper) that warps the cursor to (0, 0) will trigger the exception intermittently. Log pyautogui.position() in the except FailSafeException handler to see where the pointer was.
  • Locked workstation on console: Locking switches the input desktop to Winlogon, and GetCursorPos fails exactly as in a disconnected RDP session. Fix the lock policy in step 3, not the script.
See also  Resolving WordPress transient cache buildup causing 504 Gateway Timeout on admin-ajax.php

I can also put this into a Markdown file for you if you’d like.

Leave a Comment