0
0
Fork 0

fix(watcher): swallow KeyboardInterrupt so CODESYS Cancel link doesn't crash the watcher

When the user clicks "Click here to CANCEL this operation" in CODESYS,
IronPython injects KeyboardInterrupt into the running script. The watcher's
try/except blocks only caught Exception, which doesn't include
KeyboardInterrupt — so the interrupt propagated to the host and CODESYS
popped the modal "Running script ... caused exception ... KeyboardInterrupt:
Script aborted by user." dialog. The watcher process then died, taking the
MCP IPC channel with it.

Three layers of handling:

  1. execute_script(): catch KeyboardInterrupt and convert it into a normal
     command failure ("Aborted by user (Cancel pressed in CODESYS)") so the
     in-flight command fails gracefully and the loop continues.

  2. Main poll loop: catch KeyboardInterrupt around the iteration body, log
     it, and continue. New _safe_delay() helper wraps system.delay() with
     its own KeyboardInterrupt swallow because the cancel link almost always
     hits during the delay (line 231 in 0.4.0).

  3. Outer try: explicit KeyboardInterrupt arm so a cancel that fires before
     the loop (during scriptengine import or directory setup) still exits
     quietly without the modal traceback dialog.

Bumps WATCHER_VERSION to 0.4.1. Verification pending — needs user to
click the Cancel link in CODESYS after a fresh launch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Karstein Phobic Nyvold Kvistad 2026-04-25 15:05:22 +02:00
parent a9347f7677
commit 93a105a644

View file

@ -25,7 +25,7 @@ IPC_BASE_DIR = r"{IPC_BASE_DIR}"
COMMANDS_DIR = os.path.join(IPC_BASE_DIR, "commands")
RESULTS_DIR = os.path.join(IPC_BASE_DIR, "results")
POLL_INTERVAL = 50 # milliseconds
WATCHER_VERSION = "0.4.0"
WATCHER_VERSION = "0.4.1"
# --- Error capture file (written before anything else can fail) ---
_ERROR_FILE = os.path.join(IPC_BASE_DIR, "watcher_error.txt")
@ -142,6 +142,12 @@ try:
elif isinstance(exit_code, str):
success = False
error = exit_code
except KeyboardInterrupt:
# User pressed "Cancel this operation" in CODESYS during this command.
# Abort just this command; the watcher loop continues.
output = capture.getvalue()
error = "Aborted by user (Cancel pressed in CODESYS)"
success = False
except Exception as e:
output = capture.getvalue()
error = "%s: %s\n%s" % (type(e).__name__, str(e), traceback.format_exc())
@ -211,6 +217,19 @@ try:
print("[WATCHER] Python version: %s" % sys.version)
_log("Watcher main loop entered")
def _safe_delay(ms):
"""Yield via system.delay() but swallow KeyboardInterrupt.
CODESYS injects KeyboardInterrupt into the script when the user
clicks "Click here to CANCEL this operation" in the IDE. The
watcher should keep running across that only an explicit
terminate.signal or process kill should stop it.
"""
try:
se.system.delay(ms)
except KeyboardInterrupt:
_log("KeyboardInterrupt during system.delay() — ignored, watcher continues")
while True:
try:
if _terminate_requested():
@ -224,14 +243,22 @@ try:
])
if cmd_files:
process_command(cmd_files[0])
except KeyboardInterrupt:
_log("KeyboardInterrupt during loop iteration — ignored, watcher continues")
except Exception as e:
_log("Loop error: %s\n%s" % (e, traceback.format_exc()))
# Yield: serves the message loop so the UI stays interactive.
se.system.delay(POLL_INTERVAL)
_safe_delay(POLL_INTERVAL)
_log("Watcher main loop exited")
except KeyboardInterrupt:
# Last-resort: a Cancel that fires before the loop is even reached
# (e.g. during scriptengine import or directory setup) should still
# exit quietly without the CODESYS exception dialog.
_write_error("KeyboardInterrupt outside main loop — exiting quietly")
print("[WATCHER] Cancelled by user before main loop; exiting.")
except Exception as _fatal:
_write_error("FATAL: %s\n%s" % (_fatal, traceback.format_exc()))
print("[WATCHER] FATAL ERROR: %s" % _fatal)