fix: drop bg-thread + execute_on_primary_thread for SP21+/SP22
Replace the .NET background-thread + system.execute_on_primary_thread marshalling design with a single-threaded primary-thread polling loop that yields to the IDE via system.delay(). The deprecated execute_on_primary_thread API was removed in CODESYS V3.5 SP21+ — see docs/MIGRATION-SP21-PLUS.md for full evidence and rationale. - watcher.py: drop clr / System.Threading imports, drop ManualResetEvent + done_event + shared_result cross-thread machinery, keep file-based IPC and OutputCapture untouched. - Loop checks for terminate.signal at the top of each iteration, then serves the message loop via system.delay(POLL_INTERVAL). - Bumped WATCHER_VERSION to 0.4.0. Verified against: - CODESYS 3.5.22.10 (SP22 Patch 1) — open_project + get_application_state both succeed where they previously failed with the marshal error. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
eae7d07cdf
commit
f525da973a
1 changed files with 106 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.0"
|
||||
|
||||
# --- 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,76 @@ 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 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 +183,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 +202,37 @@ 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()
|
||||
while True:
|
||||
try:
|
||||
if _terminate_requested():
|
||||
_log("Terminate signal received")
|
||||
print("[WATCHER] Terminate signal received, exiting")
|
||||
break
|
||||
|
||||
import System
|
||||
System.GC.KeepAlive(t)
|
||||
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("Loop error: %s\n%s" % (e, traceback.format_exc()))
|
||||
|
||||
print("[WATCHER] Background thread started, script returning - UI is free")
|
||||
# Yield: serves the message loop so the UI stays interactive.
|
||||
se.system.delay(POLL_INTERVAL)
|
||||
|
||||
_log("Watcher main loop exited")
|
||||
|
||||
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