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:
parent
a9347f7677
commit
93a105a644
1 changed files with 29 additions and 2 deletions
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue