fix(watcher): SP21+/SP22 compatibility -- drop bg-thread + execute_on_primary_thread
CODESYS V3.5 SP21 removed system.execute_on_primary_thread(), so the existing watcher fails on SP21/SP22 with: Marshal error: The functionality 'system.execute_on_primary_thread(...)' is no longer supported Every tool call returns this and the server is unusable on current CODESYS releases. Rewrite the watcher as single-threaded on the primary thread, polling the commands/ directory and yielding to the IDE via system.delay() between iterations. No background thread, no marshaling. The UI stays responsive between commands; synchronous CODESYS API calls (compile, project open) briefly pause the UI as they always have. Tested on SP19, SP21 Patch 5, and SP22 Patch 1. Also folds in two related watcher hardening fixes: - Catch KeyboardInterrupt at three layers so the CODESYS 'Click here to CANCEL this operation' link no longer pops a modal traceback or kills the watcher (KeyboardInterrupt is not a subclass of Exception in IronPython 2.7). - Use ASCII-only comments/log strings -- IronPython 2.7 source files reject non-ASCII without a coding declaration.
This commit is contained in:
parent
aad246576c
commit
63013e955c
1 changed files with 133 additions and 139 deletions
|
|
@ -1,9 +1,16 @@
|
|||
"""
|
||||
Persistent watcher script for CODESYS IPC.
|
||||
Runs inside CODESYS via --runscript, starts a background polling thread,
|
||||
then RETURNS so the CODESYS UI stays interactive.
|
||||
|
||||
Commands are marshaled to the primary thread via execute_on_primary_thread.
|
||||
Runs inside CODESYS via --runscript on the primary (UI) thread.
|
||||
Polls a commands/ directory and executes each command directly on the
|
||||
primary thread (no marshalling). Yields to the IDE between polls via
|
||||
``system.delay()``, which serves the message loop and keeps the UI
|
||||
interactive.
|
||||
|
||||
Why no background thread? CODESYS V3.5 SP21+ removed
|
||||
``system.execute_on_primary_thread()``, the API older versions of this
|
||||
watcher used to marshal work from a .NET background thread back to the
|
||||
UI thread. The single-thread design here works on SP19, SP21, and SP22+.
|
||||
|
||||
{IPC_BASE_DIR} is interpolated by Node.js before launch.
|
||||
"""
|
||||
|
|
@ -18,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.3.0"
|
||||
WATCHER_VERSION = "0.4.2"
|
||||
|
||||
# --- Error capture file (written before anything else can fail) ---
|
||||
_ERROR_FILE = os.path.join(IPC_BASE_DIR, "watcher_error.txt")
|
||||
|
|
@ -48,7 +55,7 @@ try:
|
|||
os.remove(file_path)
|
||||
os.rename(tmp_path, file_path)
|
||||
|
||||
# --- Write ready signal EARLY (before .NET imports) ---
|
||||
# --- Write ready signal EARLY ---
|
||||
ready_path = os.path.join(IPC_BASE_DIR, "ready.signal")
|
||||
info = {
|
||||
"version": WATCHER_VERSION,
|
||||
|
|
@ -61,16 +68,12 @@ try:
|
|||
atomic_write(ready_path, json.dumps(info, indent=2))
|
||||
print("[WATCHER] Ready signal written to %s" % ready_path)
|
||||
|
||||
# --- Import .NET threading (after ready signal) ---
|
||||
_write_error("About to import clr")
|
||||
import clr
|
||||
_write_error("clr imported OK")
|
||||
from System.Threading import Thread, ThreadStart, ManualResetEvent
|
||||
_write_error("System.Threading imported OK")
|
||||
# --- Import scripting engine ---
|
||||
_write_error("About to import scriptengine")
|
||||
import scriptengine as se
|
||||
_write_error("scriptengine imported OK")
|
||||
|
||||
# --- File-based logging (print from bg thread crashes CODESYS) ---
|
||||
# --- File-based logging ---
|
||||
_LOG_FILE = os.path.join(IPC_BASE_DIR, "watcher.log")
|
||||
|
||||
def _log(msg):
|
||||
|
|
@ -93,18 +96,82 @@ try:
|
|||
def getvalue(self):
|
||||
return ''.join(self._buffer)
|
||||
|
||||
# --- Stop event ---
|
||||
_stop_event = ManualResetEvent(False)
|
||||
def execute_script(script_code, request_id):
|
||||
"""Execute script_code synchronously on the current (primary) thread.
|
||||
Returns the result dict to be written to results/."""
|
||||
success = False
|
||||
output = ""
|
||||
error = ""
|
||||
old_stdout = sys.stdout
|
||||
old_stderr = sys.stderr
|
||||
capture = OutputCapture()
|
||||
sys.stdout = capture
|
||||
sys.stderr = capture
|
||||
try:
|
||||
exec_globals = {
|
||||
'__builtins__': __builtins__,
|
||||
'sys': sys,
|
||||
'os': os,
|
||||
'time': time,
|
||||
'traceback': traceback,
|
||||
'shutil': __import__('shutil'),
|
||||
}
|
||||
exec(script_code, exec_globals)
|
||||
output = capture.getvalue()
|
||||
if "SCRIPT_ERROR" in output:
|
||||
success = False
|
||||
error = "Script reported error via SCRIPT_ERROR marker"
|
||||
elif "SCRIPT_SUCCESS" in output:
|
||||
success = True
|
||||
else:
|
||||
success = True
|
||||
except SystemExit as e:
|
||||
output = capture.getvalue()
|
||||
exit_code = e.code
|
||||
if exit_code is None or exit_code == 0:
|
||||
success = True
|
||||
if "SCRIPT_ERROR" in output:
|
||||
success = False
|
||||
error = "Script reported error via SCRIPT_ERROR marker"
|
||||
elif isinstance(exit_code, int):
|
||||
if "SCRIPT_SUCCESS" in output and "SCRIPT_ERROR" not in output:
|
||||
success = True
|
||||
else:
|
||||
success = False
|
||||
error = "Script exited with code %s" % exit_code
|
||||
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())
|
||||
success = False
|
||||
finally:
|
||||
sys.stdout = old_stdout
|
||||
sys.stderr = old_stderr
|
||||
|
||||
return {
|
||||
"requestId": request_id,
|
||||
"success": success,
|
||||
"output": output,
|
||||
"error": error,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
|
||||
def process_command(command_file):
|
||||
"""Process a single command. File I/O on bg thread, exec on primary thread."""
|
||||
"""Process a single command file end-to-end on the primary thread."""
|
||||
command_path = os.path.join(COMMANDS_DIR, command_file)
|
||||
request_id = command_file.replace(".command.json", "")
|
||||
result_path = os.path.join(RESULTS_DIR, "%s.result.json" % request_id)
|
||||
|
||||
_log("Processing command: %s" % request_id)
|
||||
|
||||
# Read command and script (file I/O - safe from bg thread)
|
||||
try:
|
||||
with open(command_path, "r") as f:
|
||||
command_data = json.loads(f.read())
|
||||
|
|
@ -122,106 +189,16 @@ try:
|
|||
"error": "Read error: %s" % read_err,
|
||||
"timestamp": time.time(),
|
||||
}))
|
||||
_cleanup_command_files(command_path, request_id)
|
||||
return
|
||||
|
||||
# Cross-thread communication
|
||||
shared_result = [None]
|
||||
done_event = ManualResetEvent(False)
|
||||
result = execute_script(script_code, request_id)
|
||||
atomic_write(result_path, json.dumps(result))
|
||||
_log("Result written: success=%s" % result.get("success"))
|
||||
|
||||
def execute_on_ui():
|
||||
success = False
|
||||
output = ""
|
||||
error = ""
|
||||
old_stdout = sys.stdout
|
||||
old_stderr = sys.stderr
|
||||
capture = OutputCapture()
|
||||
sys.stdout = capture
|
||||
sys.stderr = capture
|
||||
try:
|
||||
exec_globals = {
|
||||
'__builtins__': __builtins__,
|
||||
'sys': sys,
|
||||
'os': os,
|
||||
'time': time,
|
||||
'traceback': traceback,
|
||||
'shutil': __import__('shutil'),
|
||||
}
|
||||
exec(script_code, exec_globals)
|
||||
output = capture.getvalue()
|
||||
if "SCRIPT_ERROR" in output:
|
||||
success = False
|
||||
error = "Script reported error via SCRIPT_ERROR marker"
|
||||
elif "SCRIPT_SUCCESS" in output:
|
||||
success = True
|
||||
else:
|
||||
success = True
|
||||
except SystemExit as e:
|
||||
output = capture.getvalue()
|
||||
exit_code = e.code
|
||||
if exit_code is None or exit_code == 0:
|
||||
success = True
|
||||
if "SCRIPT_ERROR" in output:
|
||||
success = False
|
||||
error = "Script reported error via SCRIPT_ERROR marker"
|
||||
elif isinstance(exit_code, int):
|
||||
if "SCRIPT_SUCCESS" in output and "SCRIPT_ERROR" not in output:
|
||||
success = True
|
||||
else:
|
||||
success = False
|
||||
error = "Script exited with code %s" % exit_code
|
||||
elif isinstance(exit_code, str):
|
||||
success = False
|
||||
error = exit_code
|
||||
except Exception as e:
|
||||
output = capture.getvalue()
|
||||
error = "%s: %s\n%s" % (type(e).__name__, str(e), traceback.format_exc())
|
||||
success = False
|
||||
finally:
|
||||
sys.stdout = old_stdout
|
||||
sys.stderr = old_stderr
|
||||
_cleanup_command_files(command_path, request_id)
|
||||
|
||||
shared_result[0] = {
|
||||
"requestId": request_id,
|
||||
"success": success,
|
||||
"output": output,
|
||||
"error": error,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
done_event.Set()
|
||||
|
||||
# Marshal execution to the primary thread
|
||||
_log("Marshaling to primary thread...")
|
||||
try:
|
||||
se.system.execute_on_primary_thread(execute_on_ui)
|
||||
except Exception as marshal_err:
|
||||
_log("Marshal error: %s" % marshal_err)
|
||||
shared_result[0] = {
|
||||
"requestId": request_id,
|
||||
"success": False,
|
||||
"output": "",
|
||||
"error": "Marshal error: %s" % marshal_err,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
done_event.Set()
|
||||
|
||||
# Wait for completion (2 min timeout)
|
||||
done_event.WaitOne(120000)
|
||||
|
||||
# Write result (file I/O - safe from bg thread)
|
||||
if shared_result[0]:
|
||||
atomic_write(result_path, json.dumps(shared_result[0]))
|
||||
_log("Result written: success=%s" % shared_result[0].get("success"))
|
||||
else:
|
||||
_log("ERROR: No result after timeout")
|
||||
atomic_write(result_path, json.dumps({
|
||||
"requestId": request_id,
|
||||
"success": False,
|
||||
"output": "",
|
||||
"error": "Timeout waiting for primary thread execution",
|
||||
"timestamp": time.time(),
|
||||
}))
|
||||
|
||||
# Cleanup command and script files
|
||||
def _cleanup_command_files(command_path, request_id):
|
||||
try:
|
||||
if os.path.exists(command_path):
|
||||
os.remove(command_path)
|
||||
|
|
@ -231,41 +208,58 @@ try:
|
|||
except:
|
||||
pass
|
||||
|
||||
def _terminate_requested():
|
||||
return os.path.exists(os.path.join(IPC_BASE_DIR, "terminate.signal"))
|
||||
|
||||
def worker():
|
||||
_log("Background worker started")
|
||||
while not _stop_event.WaitOne(POLL_INTERVAL):
|
||||
try:
|
||||
if os.path.exists(os.path.join(IPC_BASE_DIR, "terminate.signal")):
|
||||
_log("Terminate signal received")
|
||||
break
|
||||
cmd_files = sorted([
|
||||
f for f in os.listdir(COMMANDS_DIR)
|
||||
if f.endswith(".command.json")
|
||||
])
|
||||
if cmd_files:
|
||||
process_command(cmd_files[0])
|
||||
except Exception as e:
|
||||
_log("Worker error: %s" % e)
|
||||
_log("Background worker stopped")
|
||||
|
||||
|
||||
# --- Start background worker thread and RETURN ---
|
||||
print("[WATCHER] Starting background watcher v%s" % WATCHER_VERSION)
|
||||
# --- Main loop on the primary thread ---
|
||||
print("[WATCHER] Starting watcher v%s (single-thread, primary)" % WATCHER_VERSION)
|
||||
print("[WATCHER] IPC directory: %s" % IPC_BASE_DIR)
|
||||
print("[WATCHER] Python version: %s" % sys.version)
|
||||
_log("Watcher main loop entered")
|
||||
|
||||
t = Thread(ThreadStart(worker))
|
||||
t.IsBackground = True
|
||||
t.Start()
|
||||
def _safe_delay(ms):
|
||||
"""Yield via system.delay() but swallow KeyboardInterrupt.
|
||||
|
||||
import System
|
||||
System.GC.KeepAlive(t)
|
||||
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")
|
||||
|
||||
print("[WATCHER] Background thread started, script returning - UI is free")
|
||||
while True:
|
||||
try:
|
||||
if _terminate_requested():
|
||||
_log("Terminate signal received")
|
||||
print("[WATCHER] Terminate signal received, exiting")
|
||||
break
|
||||
|
||||
cmd_files = sorted([
|
||||
f for f in os.listdir(COMMANDS_DIR)
|
||||
if f.endswith(".command.json")
|
||||
])
|
||||
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.
|
||||
_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)
|
||||
traceback.print_exc()
|
||||
# Script returns here. CODESYS UI thread is freed.
|
||||
|
|
|
|||
Loading…
Reference in a new issue