@codesys/mcp-toolkit 2.1.0 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,326 +1,326 @@
1
- # -*- coding: utf-8 -*-
2
- # codesys-mcp-toolkit v2 watcher.
3
- #
4
- # Started by the MCP server as: CODESYS.exe --profile="..." --noUI --runscript="<dir>/watcher.py"
5
- # Environment (inherited from the server):
6
- # CODESYS_MCP_DIR session folder (requests/, results/, heartbeat.json, ready.json, watcher.log)
7
- # CODESYS_MCP_SCRIPTS folder holding this file and its modules
8
- # CODESYS_MCP_PARENT_PID server PID; the watcher exits when it is gone
9
- #
10
- # Protocol: the server writes requests/<id>.json ({"id","op","args","deadline_ms"}) via tmp+rename.
11
- # The watcher handles one request at a time and writes results/<id>.json
12
- # ({"id","ok","result"} or {"id","ok":false,"error":{"code","message","detail"}}).
13
- # Requests are JSON op names + arguments; no code is ever generated or exec'd.
14
- # Attach mode (CODESYS_MCP_ATTACH=1, see start_attach.py written by the server): run from
15
- # the user's open window; a UI timer polls for requests instead of a blocking loop. The
16
- # launcher runs only this file (the toolkit's own); requests still never become code.
17
- #
18
- # Keep this file ASCII-only (IronPython 2.7 inside CODESYS).
19
- from __future__ import print_function
20
-
21
- import os
22
- import re
23
- import sys
24
- import time
25
- import traceback
26
-
27
- SCRIPTS = os.environ.get("CODESYS_MCP_SCRIPTS")
28
- if SCRIPTS and SCRIPTS not in sys.path:
29
- sys.path.insert(0, SCRIPTS)
30
-
31
- from mcp_compat import current_pid, now, pid_alive, read_json, remove_file, retry_fs, to_text, write_json_atomic # noqa: E402
32
-
33
- SESSION = os.environ["CODESYS_MCP_DIR"]
34
- PARENT_PID = int(os.environ.get("CODESYS_MCP_PARENT_PID") or "0")
35
- REQ_DIR = os.path.join(SESSION, "requests")
36
- RES_DIR = os.path.join(SESSION, "results")
37
- HEARTBEAT = os.path.join(SESSION, "heartbeat.json")
38
- READY = os.path.join(SESSION, "ready.json")
39
- LOG = os.path.join(SESSION, "watcher.log")
40
-
41
- DIALOG = os.path.join(SESSION, "dialog.json")
42
-
43
- PID = current_pid()
44
- STATE = {"count": 0, "last_beat": 0.0, "busy_since": None, "op": None}
45
-
46
- # A dialog must stay up this long before the server is told, so brief windows don't count.
47
- DIALOG_GRACE_S = 10.0
48
-
49
-
50
- def log(msg):
51
- try:
52
- f = open(LOG, "a")
53
- try:
54
- f.write("%s %s\n" % (time.strftime("%H:%M:%S"), to_text(msg).encode("ascii", "replace").decode("ascii")))
55
- finally:
56
- f.close()
57
- except Exception:
58
- pass
59
-
60
-
61
- def beat(state, op=None, rid=None, deadline_ms=None):
62
- # A heartbeat that cannot be written (file held open past the retries) must not
63
- # kill the watcher: log it and go on. If it keeps failing, the server sees a
64
- # stale heartbeat and reports the watcher as hung, which is the right signal.
65
- t = now()
66
- try:
67
- write_json_atomic(HEARTBEAT, {
68
- "pid": PID, "ts": t, "state": state, "op": op, "id": rid,
69
- "deadline": (t + deadline_ms / 1000.0) if deadline_ms else None,
70
- "count": STATE["count"],
71
- })
72
- except Exception as e:
73
- if t - STATE.get("last_beat_err", 0.0) > 30.0:
74
- STATE["last_beat_err"] = t
75
- log("heartbeat write failed (%s): %s\n%s" % (state, to_text(e), to_text(traceback.format_exc())))
76
- STATE["last_beat"] = t
77
-
78
-
79
- def setup_prompts(se):
80
- # Never block on a modal dialog: suppress with defaults and log them.
81
- # SP21 only has the old prompt_handling; newer versions have script_prompt_handling.
82
- try:
83
- se.system.script_prompt_handling = se.ScriptPromptHandling.LogPrompts
84
- return "script_prompt_handling=LogPrompts"
85
- except Exception:
86
- pass
87
- try:
88
- se.system.prompt_handling = se.PromptHandling.LogSimplePrompts
89
- return "prompt_handling=LogSimplePrompts"
90
- except Exception as e:
91
- return "prompt handling not set: %s" % e
92
-
93
-
94
- def scripting_version():
95
- # In CODESYS, sys.version ends in "ScriptEngine.plugin 4.2.0.0".
96
- m = re.search(r"ScriptEngine\.plugin\s+([0-9.]+)", sys.version)
97
- return m.group(1) if m else None
98
-
99
-
100
- def find_dialog():
101
- # A session without a UI has no open forms and no main window (checked on SP21 and
102
- # SP22), so a modal form or a main window means CODESYS is waiting for an answer.
103
- from System.Windows.Forms import Application
104
- from System.Diagnostics import Process
105
- for f in Application.OpenForms:
106
- if f.Modal:
107
- return to_text(f.Text) or "(untitled)"
108
- return to_text(Process.GetCurrentProcess().MainWindowTitle) or None
109
-
110
-
111
- def watch_dialogs():
112
- # Runs beside the request loop. A dialog blocks that loop for good and nobody can
113
- # answer it in a session without a window, so tell the server (dialog.json).
114
- import clr
115
- clr.AddReference("System.Windows.Forms")
116
- title, since, reported, failed = None, 0.0, False, False
117
- while True:
118
- time.sleep(1.0)
119
- busy = STATE["busy_since"]
120
- current = None
121
- if busy is not None and now() - busy > 2.0:
122
- try:
123
- current = find_dialog()
124
- except Exception as e: # e.g. the form list changed while it was read
125
- if not failed:
126
- log("dialog check failed: %s" % to_text(e))
127
- failed = True
128
- if current != title:
129
- title, since = current, now()
130
- if current:
131
- log("dialog up during %s: %s" % (STATE["op"], current))
132
- elif reported:
133
- remove_file(DIALOG)
134
- reported = False
135
- elif current and not reported and now() - since >= DIALOG_GRACE_S:
136
- write_json_atomic(DIALOG, {"title": current, "op": STATE["op"]})
137
- reported = True
138
-
139
-
140
- def start_dialog_watch():
141
- import threading
142
-
143
- def run():
144
- try:
145
- watch_dialogs()
146
- except BaseException as e:
147
- log("dialog watch stopped: %s" % to_text(e))
148
- t = threading.Thread(target=run)
149
- t.daemon = True
150
- t.start()
151
-
152
-
153
- def sleep_ms(se, ms):
154
- delay = getattr(getattr(se, "system", None), "delay", None)
155
- if delay is not None and not hasattr(se, "FAKE"):
156
- try:
157
- delay(ms)
158
- return
159
- except Exception:
160
- pass
161
- time.sleep(ms / 1000.0)
162
-
163
-
164
- def next_request():
165
- try:
166
- names = sorted(n for n in os.listdir(REQ_DIR) if n.endswith(".json"))
167
- except OSError:
168
- return None
169
- return names[0] if names else None
170
-
171
-
172
- def handle(ops_mod, fname):
173
- path = os.path.join(REQ_DIR, fname)
174
- rid = fname[:-5]
175
- try:
176
- # Another process (an antivirus scan, the indexer) can hold a new file for a moment.
177
- req = retry_fs(read_json, path)
178
- except Exception as e:
179
- remove_file(path)
180
- write_json_atomic(os.path.join(RES_DIR, rid + ".json"), {
181
- "id": rid, "ok": False, "error": {"code": "bad_request", "message": "Unreadable request: %s" % e}})
182
- return True
183
- remove_file(path)
184
- rid = req.get("id") or rid
185
- op = req.get("op")
186
- args = req.get("args") or {}
187
- STATE["count"] += 1
188
- beat("busy", op, rid, req.get("deadline_ms"))
189
- started = now()
190
- STATE["op"] = op
191
- STATE["busy_since"] = started
192
- stop = False
193
- if op == "shutdown":
194
- res = {"id": rid, "ok": True, "result": {"bye": True}}
195
- stop = True
196
- else:
197
- fn = ops_mod.OPS.get(op)
198
- if fn is None:
199
- res = {"id": rid, "ok": False, "error": {"code": "unknown_op", "message": "Unknown op: %s" % op}}
200
- else:
201
- try:
202
- res = {"id": rid, "ok": True, "result": fn(args)}
203
- except ops_mod.OpError as e:
204
- res = {"id": rid, "ok": False, "error": {"code": e.code, "message": to_text(e.message)}}
205
- except BaseException as e: # IronPython: KeyboardInterrupt is not an Exception
206
- res = {"id": rid, "ok": False, "error": {
207
- "code": "codesys_error", "message": to_text(e) or e.__class__.__name__,
208
- "detail": to_text(traceback.format_exc())}}
209
- STATE["busy_since"] = None
210
- res["ms"] = int((now() - started) * 1000)
211
- # Go idle before publishing the result: once the server sees the result it may
212
- # read the heartbeat right away, and it must not find this op still "busy".
213
- beat("idle")
214
- write_json_atomic(os.path.join(RES_DIR, rid + ".json"), res)
215
- log("%s %s %s %dms" % (rid, op, "ok" if res.get("ok") else res["error"]["code"], res["ms"]))
216
- return not stop
217
-
218
-
219
- def main():
220
- for d in (REQ_DIR, RES_DIR):
221
- if not os.path.isdir(d):
222
- os.makedirs(d)
223
- log("watcher starting pid=%s parent=%s python=%s" % (PID, PARENT_PID, sys.version.split()[0]))
224
- import scriptengine as se
225
- import mcp_ops
226
- import mcp_sim # noqa: F401 (adds its operations to mcp_ops.OPS)
227
- prompts = setup_prompts(se)
228
- log(prompts)
229
- if not hasattr(se, "FAKE"):
230
- start_dialog_watch()
231
- beat("idle")
232
- write_json_atomic(READY, {"pid": PID, "ts": now(), "python": sys.version.split()[0], "prompts": prompts, "scripting": scripting_version()})
233
- last_parent_check = 0.0
234
- while True:
235
- t = now()
236
- if PARENT_PID and t - last_parent_check > 2.0:
237
- last_parent_check = t
238
- if not pid_alive(PARENT_PID):
239
- log("parent %s gone; exiting" % PARENT_PID)
240
- break
241
- fname = next_request()
242
- if fname:
243
- if not handle(mcp_ops, fname):
244
- log("shutdown requested")
245
- break
246
- continue
247
- if t - STATE["last_beat"] > 1.0:
248
- beat("idle")
249
- sleep_ms(se, 40)
250
- beat("stopped")
251
-
252
-
253
- def main_attach():
254
- # Attach mode: the user's own window runs this script (Tools > Scripting > Execute
255
- # Script File). A blocking loop would freeze their window, so a WinForms timer polls
256
- # for requests on the UI thread and the script returns at once. The window is only
257
- # busy while a request runs. No dialog watch: a window and dialogs are expected here.
258
- for d in (REQ_DIR, RES_DIR):
259
- if not os.path.isdir(d):
260
- os.makedirs(d)
261
- log("attach watcher starting pid=%s python=%s" % (PID, sys.version.split()[0]))
262
- # This runs on the user's UI thread: never block it for seconds on a busy file.
263
- import mcp_compat
264
- mcp_compat.FS_RETRY_S = 0.3
265
- import clr
266
- clr.AddReference("System.Windows.Forms")
267
- from System.Windows.Forms import Timer
268
- import __builtin__
269
- import scriptengine as se
270
- import mcp_ops
271
- import mcp_sim # noqa: F401 (adds its operations to mcp_ops.OPS)
272
- prompts = setup_prompts(se)
273
- log(prompts)
274
- old = getattr(__builtin__, "_mcp_attach_timer", None)
275
- if old is not None:
276
- old.Stop() # started twice: the new script takes over
277
- stop_flag = os.path.join(SESSION, "stop.flag")
278
- busy = {"in_tick": False}
279
-
280
- def on_tick(sender, args):
281
- # Handling a request can pump messages (waits inside operations), which fires
282
- # another tick: ignore it until the request is done.
283
- if busy["in_tick"]:
284
- return
285
- busy["in_tick"] = True
286
- try:
287
- if os.path.exists(stop_flag):
288
- remove_file(stop_flag)
289
- sender.Stop()
290
- beat("stopped")
291
- log("stop.flag found; attach watcher stopped")
292
- return
293
- fname = next_request()
294
- if fname:
295
- if not handle(mcp_ops, fname):
296
- sender.Stop()
297
- beat("stopped")
298
- log("shutdown requested")
299
- elif now() - STATE["last_beat"] > 1.0:
300
- beat("idle")
301
- except BaseException as e:
302
- log("tick failed: %s" % to_text(traceback.format_exc()))
303
- finally:
304
- busy["in_tick"] = False
305
-
306
- timer = Timer()
307
- timer.Interval = 100
308
- timer.Tick += on_tick
309
- timer.Start()
310
- __builtin__._mcp_attach_timer = timer # keep it alive after this script returns
311
- beat("idle")
312
- write_json_atomic(READY, {"pid": PID, "ts": now(), "python": sys.version.split()[0], "prompts": prompts, "scripting": scripting_version(), "attach": True})
313
- log("attach watcher running")
314
-
315
-
316
- try:
317
- if os.environ.get("CODESYS_MCP_ATTACH") == "1":
318
- main_attach()
319
- else:
320
- main()
321
- except BaseException:
322
- log("watcher crashed: " + traceback.format_exc())
323
- try:
324
- beat("crashed")
325
- except Exception:
326
- pass
1
+ # -*- coding: utf-8 -*-
2
+ # codesys-mcp-toolkit v2 watcher.
3
+ #
4
+ # Started by the MCP server as: CODESYS.exe --profile="..." --noUI --runscript="<dir>/watcher.py"
5
+ # Environment (inherited from the server):
6
+ # CODESYS_MCP_DIR session folder (requests/, results/, heartbeat.json, ready.json, watcher.log)
7
+ # CODESYS_MCP_SCRIPTS folder holding this file and its modules
8
+ # CODESYS_MCP_PARENT_PID server PID; the watcher exits when it is gone
9
+ #
10
+ # Protocol: the server writes requests/<id>.json ({"id","op","args","deadline_ms"}) via tmp+rename.
11
+ # The watcher handles one request at a time and writes results/<id>.json
12
+ # ({"id","ok","result"} or {"id","ok":false,"error":{"code","message","detail"}}).
13
+ # Requests are JSON op names + arguments; no code is ever generated or exec'd.
14
+ # Attach mode (CODESYS_MCP_ATTACH=1, see start_attach.py written by the server): run from
15
+ # the user's open window; a UI timer polls for requests instead of a blocking loop. The
16
+ # launcher runs only this file (the toolkit's own); requests still never become code.
17
+ #
18
+ # Keep this file ASCII-only (IronPython 2.7 inside CODESYS).
19
+ from __future__ import print_function
20
+
21
+ import os
22
+ import re
23
+ import sys
24
+ import time
25
+ import traceback
26
+
27
+ SCRIPTS = os.environ.get("CODESYS_MCP_SCRIPTS")
28
+ if SCRIPTS and SCRIPTS not in sys.path:
29
+ sys.path.insert(0, SCRIPTS)
30
+
31
+ from mcp_compat import current_pid, now, pid_alive, read_json, remove_file, retry_fs, to_text, write_json_atomic # noqa: E402
32
+
33
+ SESSION = os.environ["CODESYS_MCP_DIR"]
34
+ PARENT_PID = int(os.environ.get("CODESYS_MCP_PARENT_PID") or "0")
35
+ REQ_DIR = os.path.join(SESSION, "requests")
36
+ RES_DIR = os.path.join(SESSION, "results")
37
+ HEARTBEAT = os.path.join(SESSION, "heartbeat.json")
38
+ READY = os.path.join(SESSION, "ready.json")
39
+ LOG = os.path.join(SESSION, "watcher.log")
40
+
41
+ DIALOG = os.path.join(SESSION, "dialog.json")
42
+
43
+ PID = current_pid()
44
+ STATE = {"count": 0, "last_beat": 0.0, "busy_since": None, "op": None}
45
+
46
+ # A dialog must stay up this long before the server is told, so brief windows don't count.
47
+ DIALOG_GRACE_S = 10.0
48
+
49
+
50
+ def log(msg):
51
+ try:
52
+ f = open(LOG, "a")
53
+ try:
54
+ f.write("%s %s\n" % (time.strftime("%H:%M:%S"), to_text(msg).encode("ascii", "replace").decode("ascii")))
55
+ finally:
56
+ f.close()
57
+ except Exception:
58
+ pass
59
+
60
+
61
+ def beat(state, op=None, rid=None, deadline_ms=None):
62
+ # A heartbeat that cannot be written (file held open past the retries) must not
63
+ # kill the watcher: log it and go on. If it keeps failing, the server sees a
64
+ # stale heartbeat and reports the watcher as hung, which is the right signal.
65
+ t = now()
66
+ try:
67
+ write_json_atomic(HEARTBEAT, {
68
+ "pid": PID, "ts": t, "state": state, "op": op, "id": rid,
69
+ "deadline": (t + deadline_ms / 1000.0) if deadline_ms else None,
70
+ "count": STATE["count"],
71
+ })
72
+ except Exception as e:
73
+ if t - STATE.get("last_beat_err", 0.0) > 30.0:
74
+ STATE["last_beat_err"] = t
75
+ log("heartbeat write failed (%s): %s\n%s" % (state, to_text(e), to_text(traceback.format_exc())))
76
+ STATE["last_beat"] = t
77
+
78
+
79
+ def setup_prompts(se):
80
+ # Never block on a modal dialog: suppress with defaults and log them.
81
+ # SP21 only has the old prompt_handling; newer versions have script_prompt_handling.
82
+ try:
83
+ se.system.script_prompt_handling = se.ScriptPromptHandling.LogPrompts
84
+ return "script_prompt_handling=LogPrompts"
85
+ except Exception:
86
+ pass
87
+ try:
88
+ se.system.prompt_handling = se.PromptHandling.LogSimplePrompts
89
+ return "prompt_handling=LogSimplePrompts"
90
+ except Exception as e:
91
+ return "prompt handling not set: %s" % e
92
+
93
+
94
+ def scripting_version():
95
+ # In CODESYS, sys.version ends in "ScriptEngine.plugin 4.2.0.0".
96
+ m = re.search(r"ScriptEngine\.plugin\s+([0-9.]+)", sys.version)
97
+ return m.group(1) if m else None
98
+
99
+
100
+ def find_dialog():
101
+ # A session without a UI has no open forms and no main window (checked on SP21 and
102
+ # SP22), so a modal form or a main window means CODESYS is waiting for an answer.
103
+ from System.Windows.Forms import Application
104
+ from System.Diagnostics import Process
105
+ for f in Application.OpenForms:
106
+ if f.Modal:
107
+ return to_text(f.Text) or "(untitled)"
108
+ return to_text(Process.GetCurrentProcess().MainWindowTitle) or None
109
+
110
+
111
+ def watch_dialogs():
112
+ # Runs beside the request loop. A dialog blocks that loop for good and nobody can
113
+ # answer it in a session without a window, so tell the server (dialog.json).
114
+ import clr
115
+ clr.AddReference("System.Windows.Forms")
116
+ title, since, reported, failed = None, 0.0, False, False
117
+ while True:
118
+ time.sleep(1.0)
119
+ busy = STATE["busy_since"]
120
+ current = None
121
+ if busy is not None and now() - busy > 2.0:
122
+ try:
123
+ current = find_dialog()
124
+ except Exception as e: # e.g. the form list changed while it was read
125
+ if not failed:
126
+ log("dialog check failed: %s" % to_text(e))
127
+ failed = True
128
+ if current != title:
129
+ title, since = current, now()
130
+ if current:
131
+ log("dialog up during %s: %s" % (STATE["op"], current))
132
+ elif reported:
133
+ remove_file(DIALOG)
134
+ reported = False
135
+ elif current and not reported and now() - since >= DIALOG_GRACE_S:
136
+ write_json_atomic(DIALOG, {"title": current, "op": STATE["op"]})
137
+ reported = True
138
+
139
+
140
+ def start_dialog_watch():
141
+ import threading
142
+
143
+ def run():
144
+ try:
145
+ watch_dialogs()
146
+ except BaseException as e:
147
+ log("dialog watch stopped: %s" % to_text(e))
148
+ t = threading.Thread(target=run)
149
+ t.daemon = True
150
+ t.start()
151
+
152
+
153
+ def sleep_ms(se, ms):
154
+ delay = getattr(getattr(se, "system", None), "delay", None)
155
+ if delay is not None and not hasattr(se, "FAKE"):
156
+ try:
157
+ delay(ms)
158
+ return
159
+ except Exception:
160
+ pass
161
+ time.sleep(ms / 1000.0)
162
+
163
+
164
+ def next_request():
165
+ try:
166
+ names = sorted(n for n in os.listdir(REQ_DIR) if n.endswith(".json"))
167
+ except OSError:
168
+ return None
169
+ return names[0] if names else None
170
+
171
+
172
+ def handle(ops_mod, fname):
173
+ path = os.path.join(REQ_DIR, fname)
174
+ rid = fname[:-5]
175
+ try:
176
+ # Another process (an antivirus scan, the indexer) can hold a new file for a moment.
177
+ req = retry_fs(read_json, path)
178
+ except Exception as e:
179
+ remove_file(path)
180
+ write_json_atomic(os.path.join(RES_DIR, rid + ".json"), {
181
+ "id": rid, "ok": False, "error": {"code": "bad_request", "message": "Unreadable request: %s" % e}})
182
+ return True
183
+ remove_file(path)
184
+ rid = req.get("id") or rid
185
+ op = req.get("op")
186
+ args = req.get("args") or {}
187
+ STATE["count"] += 1
188
+ beat("busy", op, rid, req.get("deadline_ms"))
189
+ started = now()
190
+ STATE["op"] = op
191
+ STATE["busy_since"] = started
192
+ stop = False
193
+ if op == "shutdown":
194
+ res = {"id": rid, "ok": True, "result": {"bye": True}}
195
+ stop = True
196
+ else:
197
+ fn = ops_mod.OPS.get(op)
198
+ if fn is None:
199
+ res = {"id": rid, "ok": False, "error": {"code": "unknown_op", "message": "Unknown op: %s" % op}}
200
+ else:
201
+ try:
202
+ res = {"id": rid, "ok": True, "result": fn(args)}
203
+ except ops_mod.OpError as e:
204
+ res = {"id": rid, "ok": False, "error": {"code": e.code, "message": to_text(e.message)}}
205
+ except BaseException as e: # IronPython: KeyboardInterrupt is not an Exception
206
+ res = {"id": rid, "ok": False, "error": {
207
+ "code": "codesys_error", "message": to_text(e) or e.__class__.__name__,
208
+ "detail": to_text(traceback.format_exc())}}
209
+ STATE["busy_since"] = None
210
+ res["ms"] = int((now() - started) * 1000)
211
+ # Go idle before publishing the result: once the server sees the result it may
212
+ # read the heartbeat right away, and it must not find this op still "busy".
213
+ beat("idle")
214
+ write_json_atomic(os.path.join(RES_DIR, rid + ".json"), res)
215
+ log("%s %s %s %dms" % (rid, op, "ok" if res.get("ok") else res["error"]["code"], res["ms"]))
216
+ return not stop
217
+
218
+
219
+ def main():
220
+ for d in (REQ_DIR, RES_DIR):
221
+ if not os.path.isdir(d):
222
+ os.makedirs(d)
223
+ log("watcher starting pid=%s parent=%s python=%s" % (PID, PARENT_PID, sys.version.split()[0]))
224
+ import scriptengine as se
225
+ import mcp_ops
226
+ import mcp_sim # noqa: F401 (adds its operations to mcp_ops.OPS)
227
+ prompts = setup_prompts(se)
228
+ log(prompts)
229
+ if not hasattr(se, "FAKE"):
230
+ start_dialog_watch()
231
+ beat("idle")
232
+ write_json_atomic(READY, {"pid": PID, "ts": now(), "python": sys.version.split()[0], "prompts": prompts, "scripting": scripting_version()})
233
+ last_parent_check = 0.0
234
+ while True:
235
+ t = now()
236
+ if PARENT_PID and t - last_parent_check > 2.0:
237
+ last_parent_check = t
238
+ if not pid_alive(PARENT_PID):
239
+ log("parent %s gone; exiting" % PARENT_PID)
240
+ break
241
+ fname = next_request()
242
+ if fname:
243
+ if not handle(mcp_ops, fname):
244
+ log("shutdown requested")
245
+ break
246
+ continue
247
+ if t - STATE["last_beat"] > 1.0:
248
+ beat("idle")
249
+ sleep_ms(se, 40)
250
+ beat("stopped")
251
+
252
+
253
+ def main_attach():
254
+ # Attach mode: the user's own window runs this script (Tools > Scripting > Execute
255
+ # Script File). A blocking loop would freeze their window, so a WinForms timer polls
256
+ # for requests on the UI thread and the script returns at once. The window is only
257
+ # busy while a request runs. No dialog watch: a window and dialogs are expected here.
258
+ for d in (REQ_DIR, RES_DIR):
259
+ if not os.path.isdir(d):
260
+ os.makedirs(d)
261
+ log("attach watcher starting pid=%s python=%s" % (PID, sys.version.split()[0]))
262
+ # This runs on the user's UI thread: never block it for seconds on a busy file.
263
+ import mcp_compat
264
+ mcp_compat.FS_RETRY_S = 0.3
265
+ import clr
266
+ clr.AddReference("System.Windows.Forms")
267
+ from System.Windows.Forms import Timer
268
+ import __builtin__
269
+ import scriptengine as se
270
+ import mcp_ops
271
+ import mcp_sim # noqa: F401 (adds its operations to mcp_ops.OPS)
272
+ prompts = setup_prompts(se)
273
+ log(prompts)
274
+ old = getattr(__builtin__, "_mcp_attach_timer", None)
275
+ if old is not None:
276
+ old.Stop() # started twice: the new script takes over
277
+ stop_flag = os.path.join(SESSION, "stop.flag")
278
+ busy = {"in_tick": False}
279
+
280
+ def on_tick(sender, args):
281
+ # Handling a request can pump messages (waits inside operations), which fires
282
+ # another tick: ignore it until the request is done.
283
+ if busy["in_tick"]:
284
+ return
285
+ busy["in_tick"] = True
286
+ try:
287
+ if os.path.exists(stop_flag):
288
+ remove_file(stop_flag)
289
+ sender.Stop()
290
+ beat("stopped")
291
+ log("stop.flag found; attach watcher stopped")
292
+ return
293
+ fname = next_request()
294
+ if fname:
295
+ if not handle(mcp_ops, fname):
296
+ sender.Stop()
297
+ beat("stopped")
298
+ log("shutdown requested")
299
+ elif now() - STATE["last_beat"] > 1.0:
300
+ beat("idle")
301
+ except BaseException as e:
302
+ log("tick failed: %s" % to_text(traceback.format_exc()))
303
+ finally:
304
+ busy["in_tick"] = False
305
+
306
+ timer = Timer()
307
+ timer.Interval = 100
308
+ timer.Tick += on_tick
309
+ timer.Start()
310
+ __builtin__._mcp_attach_timer = timer # keep it alive after this script returns
311
+ beat("idle")
312
+ write_json_atomic(READY, {"pid": PID, "ts": now(), "python": sys.version.split()[0], "prompts": prompts, "scripting": scripting_version(), "attach": True})
313
+ log("attach watcher running")
314
+
315
+
316
+ try:
317
+ if os.environ.get("CODESYS_MCP_ATTACH") == "1":
318
+ main_attach()
319
+ else:
320
+ main()
321
+ except BaseException:
322
+ log("watcher crashed: " + traceback.format_exc())
323
+ try:
324
+ beat("crashed")
325
+ except Exception:
326
+ pass