@alotop/dsh-matlab-bridge 0.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.
@@ -0,0 +1,419 @@
1
+ #!/usr/bin/env python3
2
+ """DSH MATLAB bridge driver.
3
+
4
+ Owns exactly one persistent MATLAB Engine session and speaks newline-delimited
5
+ JSON on stdin/stdout.
6
+
7
+ Why every protocol line carries an `@@DSH:` prefix: the MATLAB Engine forwards
8
+ the MATLAB command window to this process's stdout, so a bare JSON protocol
9
+ would race with MATLAB's own output. Lines carrying the prefix are protocol;
10
+ every other line is MATLAB chatter that the caller can surface as diagnostics.
11
+
12
+ Request: {"id": <int>, "op": "<name>", ...}
13
+ Response: {"id": <int>, "ok": <bool>, ...}
14
+
15
+ Ops
16
+ ping liveness + whether MATLAB is up
17
+ start launch MATLAB (idempotent)
18
+ eval {code} run code in the base workspace
19
+ debug {action, ...} breakpoints, stepping, frame inspection
20
+ figure {action, ...} list, export and close figures
21
+ shutdown quit MATLAB and exit
22
+
23
+ Figure actions
24
+ list every open figure, in creation order
25
+ save {figure, dir} export to PNG; figure=0 means all
26
+ close {figure} close one figure, or all when 0
27
+
28
+ Debug actions
29
+ break {file, line} dbstop in <file> at <line>
30
+ breakError dbstop if error
31
+ clearBreaks dbclear all
32
+ run {code, waitMs} launch code; stop when it pauses or ends
33
+ status paused?, stack text, future running?
34
+ stack dbstack text of the paused frame
35
+ vars whos text of the paused frame
36
+ get {name} value of NAME in the paused frame
37
+ eval {code} evaluate CODE in the paused frame
38
+ step | stepIn | stepOut | continue dbstep / dbcont
39
+ quit dbquit
40
+ finish {waitMs} await the outstanding run
41
+ """
42
+
43
+ import json
44
+ import os
45
+ import sys
46
+ import time
47
+ import warnings
48
+
49
+ # The bundled engine advertises Python 3.9-3.12 and warns on 3.13. The shipped
50
+ # abi3 module loads and runs correctly on 3.13, so the warning is pure noise on
51
+ # the stderr channel the caller surfaces as diagnostics.
52
+ warnings.filterwarnings("ignore", message=".*MATLAB Engine for Python supports Python version.*")
53
+ warnings.filterwarnings("ignore", message=".*Python versions .* are supported.*")
54
+
55
+
56
+ # Import the engine without depending on the caller setting PYTHONPATH. The
57
+ # Cordis plugin spawns this file through the subprocess seam, where building a
58
+ # full environment is awkward; a self-contained driver removes that coupling.
59
+ _HERE = os.path.dirname(os.path.abspath(__file__))
60
+ _PYLIBS = os.path.join(_HERE, "pylibs")
61
+ if os.path.isdir(_PYLIBS) and _PYLIBS not in sys.path:
62
+ sys.path.insert(0, _PYLIBS)
63
+
64
+ # Speak protocol over the raw byte streams with an explicit encoding. The
65
+ # Windows console code page is not UTF-8, and MATLAB output here is routinely
66
+ # non-ASCII (localized error messages), so relying on the locale would corrupt
67
+ # both the JSON and the captured text.
68
+ _STDIN = sys.stdin.buffer
69
+ _STDOUT = sys.stdout.buffer
70
+
71
+ PROTOCOL_PREFIX = "@@DSH:"
72
+
73
+ _engine = None
74
+ _future = None
75
+ _last_run_error = None
76
+
77
+
78
+ def emit(payload):
79
+ """Write one protocol line. A dead pipe ends the process quietly."""
80
+ try:
81
+ line = PROTOCOL_PREFIX + json.dumps(payload, ensure_ascii=False)
82
+ _STDOUT.write(line.encode("utf-8", "replace") + b"\n")
83
+ _STDOUT.flush()
84
+ except Exception:
85
+ raise SystemExit(0)
86
+
87
+
88
+ def engine():
89
+ """Return the live MATLAB engine, starting it on first use."""
90
+ global _engine
91
+ if _engine is None:
92
+ try:
93
+ import matlab.engine
94
+ except ImportError as exc:
95
+ # A bare ImportError here is the single most likely first-run
96
+ # failure, and it names neither the cause nor the fix.
97
+ raise RuntimeError(
98
+ "the MATLAB Engine for Python runtime is not available (%s). "
99
+ "Lay it out from your MATLAB installation first: "
100
+ "`npx @alotop/dsh-matlab-bridge` (or `npm run setup` in a "
101
+ "checkout). It was expected under %s." % (exc, _PYLIBS)
102
+ )
103
+ _engine = matlab.engine.start_matlab("-nodesktop")
104
+ # dsh_evalbase lives beside this driver; MATLAB must be able to see it.
105
+ helpers = os.path.join(os.path.dirname(os.path.abspath(__file__)), "mfiles")
106
+ _engine.addpath(helpers, nargout=0)
107
+ return _engine
108
+
109
+
110
+ def capture(code):
111
+ """Run CODE in the MATLAB base workspace; return (out, err, stack)."""
112
+ result = engine().dsh_evalbase(code, nargout=1)
113
+ out = result.get("out", "") or ""
114
+ err = result.get("err", "") or ""
115
+ stack = result.get("stack", "") or ""
116
+ return out, err, stack
117
+
118
+
119
+ def control(command):
120
+ """Run a debugger control statement; return (out, err).
121
+
122
+ These are command-syntax statements (`dbstop in f at 4`), never expressions,
123
+ so they bypass the base-workspace capture helper and go straight to the
124
+ engine, which runs them with no output argument.
125
+ """
126
+ try:
127
+ engine().eval(command, nargout=0)
128
+ return "", ""
129
+ except Exception as exc: # noqa: BLE001
130
+ return "", str(exc)
131
+
132
+
133
+ def dbstack_text():
134
+ """Command-window text of the current stack, empty when not debugging."""
135
+ try:
136
+ return engine().eval("evalc('dbstack')", nargout=1) or ""
137
+ except Exception as exc: # noqa: BLE001
138
+ return ""
139
+
140
+ def is_paused():
141
+ return dbstack_text().strip() != ""
142
+
143
+
144
+ def await_future(wait_ms):
145
+ """Wait up to WAIT_MS for the outstanding run; return its outcome string."""
146
+ global _future, _last_run_error
147
+ if _future is None:
148
+ return "no-run"
149
+ deadline = time.time() + (wait_ms / 1000.0)
150
+ while time.time() < deadline:
151
+ if _future.done():
152
+ try:
153
+ _future.result()
154
+ _future = None
155
+ return "completed"
156
+ except Exception as exc: # noqa: BLE001
157
+ _last_run_error = str(exc)
158
+ _future = None
159
+ return "failed"
160
+ time.sleep(0.05)
161
+ return "running"
162
+
163
+
164
+ def op_eval(req):
165
+ code = req.get("code", "")
166
+ if not code.strip():
167
+ return {"ok": True, "out": "", "err": "", "stack": ""}
168
+ out, err, stack = capture(code)
169
+ return {"ok": err == "", "out": out, "err": err, "stack": stack}
170
+
171
+
172
+ def op_debug(req):
173
+ global _future, _last_run_error
174
+ action = req.get("action", "")
175
+ eng = engine()
176
+
177
+ if action == "break":
178
+ name = (req.get("file") or "").strip()
179
+ if not name:
180
+ return {"ok": False, "err": "debug break requires 'file'"}
181
+ line = req.get("line")
182
+ command = "dbstop in %s at %d" % (name, int(line)) if line else "dbstop in %s" % name
183
+ out, err = control(command)
184
+ return {"ok": err == "", "out": out, "err": err}
185
+
186
+ if action == "breakError":
187
+ out, err = control("dbstop if error")
188
+ return {"ok": err == "", "out": out, "err": err}
189
+
190
+ if action == "clearBreaks":
191
+ out, err = control("dbclear all")
192
+ return {"ok": err == "", "out": out, "err": err}
193
+
194
+ if action == "run":
195
+ code = (req.get("code") or "").strip()
196
+ if not code:
197
+ return {"ok": False, "err": "debug run requires 'code'"}
198
+ wait_ms = int(req.get("waitMs") or 8000)
199
+ _last_run_error = None
200
+ _future = eng.eval(code, background=True, nargout=0)
201
+ deadline = time.time() + (wait_ms / 1000.0)
202
+ while time.time() < deadline:
203
+ # Check completion before probing the stack: a query issued while
204
+ # MATLAB is busy running the background call blocks until that call
205
+ # yields, so testing `done` first reports a fast run immediately.
206
+ if _future.done():
207
+ try:
208
+ _future.result()
209
+ _future = None
210
+ return {"ok": True, "state": "completed", "out": ""}
211
+ except Exception as exc: # noqa: BLE001
212
+ _future = None
213
+ return {"ok": False, "state": "failed", "err": str(exc)}
214
+ if is_paused():
215
+ return {"ok": True, "state": "paused", "stack": dbstack_text()}
216
+ time.sleep(0.1)
217
+ return {"ok": True, "state": "running", "stack": dbstack_text()}
218
+
219
+ if action == "status":
220
+ paused = is_paused()
221
+ stack = dbstack_text() if paused else ""
222
+ running = _future is not None and not _future.done()
223
+ payload = {"ok": True, "paused": paused, "running": running, "stack": stack}
224
+ if _future is not None and _future.done():
225
+ payload["finished"] = True
226
+ return payload
227
+
228
+ if action == "stack":
229
+ if not is_paused():
230
+ return {"ok": False, "err": "MATLAB is not paused at a breakpoint"}
231
+ return {"ok": True, "out": dbstack_text()}
232
+
233
+ if action == "vars":
234
+ if not is_paused():
235
+ return {"ok": False, "err": "MATLAB is not paused at a breakpoint"}
236
+ return {"ok": True, "out": eng.eval("evalc('whos')", nargout=1) or ""}
237
+
238
+ if action in ("get", "eval"):
239
+ if not is_paused():
240
+ return {"ok": False, "err": "MATLAB is not paused at a breakpoint"}
241
+ code = req.get("name") if action == "get" else req.get("code")
242
+ if not code:
243
+ return {"ok": False, "err": "debug %s requires a value" % action}
244
+ try:
245
+ value = eng.eval(str(code))
246
+ except Exception as exc: # noqa: BLE001
247
+ return {"ok": False, "err": str(exc)}
248
+ return {"ok": True, "out": _format_value(value)}
249
+
250
+ if action in ("step", "stepIn", "stepOut", "continue", "quit"):
251
+ if not is_paused():
252
+ return {"ok": False, "err": "MATLAB is not paused at a breakpoint"}
253
+ command = {
254
+ "step": "dbstep",
255
+ "stepIn": "dbstep in",
256
+ "stepOut": "dbstep out",
257
+ "continue": "dbcont",
258
+ "quit": "dbquit",
259
+ }[action]
260
+ try:
261
+ eng.eval(command, nargout=0)
262
+ except Exception as exc: # noqa: BLE001
263
+ return {"ok": False, "err": str(exc)}
264
+ if action == "continue":
265
+ outcome = await_future(int(req.get("waitMs") or 15000))
266
+ return {"ok": True, "state": outcome, "err": _last_run_error or ""}
267
+ if action == "quit":
268
+ _future = None
269
+ return {"ok": True, "state": "debug-quit"}
270
+ return {"ok": True, "state": "paused", "stack": dbstack_text()}
271
+
272
+ if action == "finish":
273
+ outcome = await_future(int(req.get("waitMs") or 15000))
274
+ return {"ok": True, "state": outcome, "err": _last_run_error or ""}
275
+
276
+ return {"ok": False, "err": "unknown debug action: %s" % action}
277
+
278
+
279
+ def _format_value(value):
280
+ """Render a MATLAB value for the model without dumping it as Python repr."""
281
+ if value is None:
282
+ return ""
283
+ if isinstance(value, float):
284
+ return repr(value)
285
+ if isinstance(value, (int, bool, str)):
286
+ return str(value)
287
+ try:
288
+ text = engine().eval("evalc('disp(value)')", nargout=1)
289
+ return text or str(value)
290
+ except Exception: # noqa: BLE001
291
+ return str(value)
292
+
293
+
294
+ def _load_json(payload, label):
295
+ """Parse the JSON text a MATLAB helper returned."""
296
+ try:
297
+ return json.loads(payload)
298
+ except Exception as exc: # noqa: BLE001
299
+ raise RuntimeError("MATLAB %s returned unparsable JSON: %s" % (label, exc))
300
+
301
+
302
+ def _figure_records(payload, label):
303
+ """Normalize a MATLAB figure payload into a list of record dicts.
304
+
305
+ JSONENCODE emits a bare OBJECT when the struct array holds exactly one
306
+ element and an ARRAY otherwise, so both shapes are legitimate. Iterating the
307
+ object directly would walk its KEY names instead of its records, which is
308
+ why this normalizes before any caller sees it.
309
+ """
310
+ parsed = _load_json(payload, label)
311
+ if isinstance(parsed, dict):
312
+ return [parsed]
313
+ if isinstance(parsed, list):
314
+ return [entry for entry in parsed if isinstance(entry, dict)]
315
+ return []
316
+
317
+
318
+ def _figure_record(entry):
319
+ """Reduce one MATLAB figure record to the scalars the caller needs."""
320
+ if not isinstance(entry, dict):
321
+ return {"number": None, "name": "", "visible": "", "path": "", "error": ""}
322
+ number = entry.get("number")
323
+ return {
324
+ "number": int(number) if isinstance(number, (int, float)) else None,
325
+ "name": str(entry.get("name") or ""),
326
+ "visible": str(entry.get("visible") or ""),
327
+ "path": str(entry.get("path") or ""),
328
+ "error": str(entry.get("error") or ""),
329
+ }
330
+
331
+
332
+ def op_figure(req):
333
+ action = req.get("action", "save")
334
+ # Validate before touching the engine: a typo must not pay a MATLAB launch.
335
+ if action not in ("list", "save", "close"):
336
+ return {"ok": False, "err": "unknown figure action: %s" % action}
337
+ eng = engine()
338
+
339
+ if action == "list":
340
+ try:
341
+ records = _figure_records(eng.dsh_figure_info(nargout=1), "dsh_figure_info")
342
+ except Exception as exc: # noqa: BLE001
343
+ return {"ok": False, "err": str(exc)}
344
+ return {"ok": True, "figures": [_figure_record(entry) for entry in records]}
345
+
346
+ if action == "save":
347
+ which = int(req.get("figure") or 0)
348
+ directory = str(req.get("dir") or os.path.join(os.getcwd(), ".matlab-figures"))
349
+ try:
350
+ records = _figure_records(
351
+ eng.dsh_figure_save(directory, float(which), nargout=1), "dsh_figure_save"
352
+ )
353
+ except Exception as exc: # noqa: BLE001
354
+ return {"ok": False, "err": str(exc)}
355
+ return {
356
+ "ok": True,
357
+ "dir": directory,
358
+ "figures": [_figure_record(entry) for entry in records],
359
+ }
360
+
361
+ if action == "close":
362
+ which = int(req.get("figure") or 0)
363
+ command = "close all" if which == 0 else "close %d" % which
364
+ out, err = control(command)
365
+ return {"ok": err == "", "out": out, "err": err}
366
+
367
+ # Unreachable: the action was validated at the top of this function.
368
+ return {"ok": False, "err": "unknown figure action: %s" % action}
369
+
370
+
371
+ def dispatch(req):
372
+ global _engine
373
+ op = req.get("op", "")
374
+ if op == "ping":
375
+ return {"ok": True, "engineRunning": _engine is not None}
376
+ if op == "start":
377
+ eng = engine()
378
+ version = eng.eval("version", nargout=1)
379
+ return {"ok": True, "version": str(version)}
380
+ if op == "eval":
381
+ return op_eval(req)
382
+ if op == "debug":
383
+ return op_debug(req)
384
+ if op == "figure":
385
+ return op_figure(req)
386
+ if op == "shutdown":
387
+ if _engine is not None:
388
+ try:
389
+ _engine.quit()
390
+ except Exception: # noqa: BLE001
391
+ pass
392
+ _engine = None
393
+ emit({"id": req.get("id"), "ok": True, "state": "shutdown"})
394
+ raise SystemExit(0)
395
+ return {"ok": False, "err": "unknown op: %s" % op}
396
+
397
+
398
+ def main():
399
+ for raw in _STDIN:
400
+ line = raw.decode("utf-8-sig", "replace").strip()
401
+ if not line:
402
+ continue
403
+ try:
404
+ req = json.loads(line)
405
+ except Exception as exc: # noqa: BLE001
406
+ emit({"id": None, "ok": False, "err": "bad request json: %s" % exc})
407
+ continue
408
+ try:
409
+ response = dispatch(req)
410
+ except SystemExit:
411
+ raise
412
+ except Exception as exc: # noqa: BLE001
413
+ response = {"ok": False, "err": "%s: %s" % (type(exc).__name__, exc)}
414
+ response["id"] = req.get("id")
415
+ emit(response)
416
+
417
+
418
+ if __name__ == "__main__":
419
+ main()
@@ -0,0 +1,244 @@
1
+ #!/usr/bin/env python3
2
+ """Self-test for the MATLAB bridge driver.
3
+
4
+ Exercises the JSON-lines protocol end to end against a real MATLAB session:
5
+ engine startup, base-workspace output capture, error reporting, figure export,
6
+ and the full breakpoint stepping sequence. Run this after touching ml_driver.py,
7
+ dsh_evalbase.m or the dsh_figure_* helpers -- a failure here is unambiguously a
8
+ driver bug rather than a Cordis plugin or transport bug.
9
+
10
+ python selftest.py # run everything
11
+ python selftest.py --no-debug # skip the MATLAB-starting debug half
12
+
13
+ Exits 0 when every check passes, 1 otherwise.
14
+ """
15
+
16
+ import argparse
17
+ import itertools
18
+ import json
19
+ import os
20
+ import subprocess
21
+ import sys
22
+ import tempfile
23
+ import time
24
+
25
+ HERE = os.path.dirname(os.path.abspath(__file__))
26
+ DRIVER = os.path.join(HERE, "ml_driver.py")
27
+ PYLIBS = os.path.join(HERE, "pylibs")
28
+ PYEXE = sys.executable
29
+
30
+ FIXTURE = """function result = bridge_fixture()
31
+ a = 1;
32
+ b = 2;
33
+ c = a + b;
34
+ d = c * 10;
35
+ result = d;
36
+ end
37
+ """
38
+
39
+ failures = []
40
+
41
+
42
+ def check(label, condition, detail=""):
43
+ status = "PASS" if condition else "FAIL"
44
+ print("[%s] %s%s" % (status, label, (" -- " + detail) if detail else ""), flush=True)
45
+ if not condition:
46
+ failures.append(label)
47
+
48
+
49
+ class Driver:
50
+ def __init__(self):
51
+ env = dict(os.environ)
52
+ env["PYTHONPATH"] = PYLIBS
53
+ env["PYTHONIOENCODING"] = "utf-8"
54
+ self.proc = subprocess.Popen(
55
+ [PYEXE, "-u", DRIVER],
56
+ stdin=subprocess.PIPE,
57
+ stdout=subprocess.PIPE,
58
+ stderr=subprocess.STDOUT,
59
+ text=True,
60
+ encoding="utf-8",
61
+ env=env,
62
+ )
63
+ self.ids = itertools.count(1)
64
+ self.chatter = []
65
+
66
+ def call(self, op, **kw):
67
+ req = {"id": next(self.ids), "op": op}
68
+ req.update(kw)
69
+ self.proc.stdin.write(json.dumps(req) + "\n")
70
+ self.proc.stdin.flush()
71
+ while True:
72
+ line = self.proc.stdout.readline()
73
+ if not line:
74
+ raise RuntimeError("driver died while waiting for %r" % op)
75
+ if line.startswith("@@DSH:"):
76
+ return json.loads(line[len("@@DSH:"):])
77
+ self.chatter.append(line.rstrip())
78
+
79
+ def close(self):
80
+ try:
81
+ self.call("shutdown")
82
+ except Exception: # noqa: BLE001
83
+ pass
84
+ try:
85
+ self.proc.wait(timeout=30)
86
+ except Exception: # noqa: BLE001
87
+ self.proc.kill()
88
+
89
+
90
+ def run_eval_checks(driver):
91
+ started = time.time()
92
+ resp = driver.call("start")
93
+ check("engine starts", resp.get("ok") is True, "version=%s" % resp.get("version"))
94
+ print(" engine start took %.1fs" % (time.time() - started), flush=True)
95
+
96
+ resp = driver.call("eval", code="x = 41")
97
+ out = resp.get("out", "")
98
+ # MATLAB's display puts a newline between `x =` and the value, so compare on
99
+ # collapsed whitespace rather than against a contiguous "x = 41".
100
+ check("assignment echoes like the command window", "x = 41" in " ".join(out.split()), repr(out))
101
+
102
+ resp = driver.call("eval", code="x + 1")
103
+ out = resp.get("out", "")
104
+ check("bare expression echoes ans", "ans" in out and "42" in out, repr(out))
105
+ check("bare expression is not echoed twice", out.count("42") == 1, repr(out))
106
+
107
+ resp = driver.call("eval", code="disp('hello');\ny = x * 2;\nfprintf('y=%d\\n', y);")
108
+ check("multi-line output captured", "hello" in resp.get("out", "") and "y=82" in resp.get("out", ""), repr(resp.get("out")))
109
+
110
+ resp = driver.call("eval", code="disp('hi')")
111
+ check("output-less call does not error", resp.get("ok") is True and resp.get("out", "").strip() == "hi", repr(resp))
112
+
113
+ resp = driver.call("eval", code="format short")
114
+ check("command syntax is not treated as an expression", resp.get("ok") is True, repr(resp))
115
+
116
+ resp = driver.call("eval", code="no_such_function_here(3)")
117
+ check("error is reported", resp.get("ok") is False and resp.get("err"), repr(resp.get("err")))
118
+ check("error carries a stack", bool(resp.get("stack")), repr(resp.get("stack")))
119
+
120
+ resp = driver.call("eval", code="x")
121
+ check("state persists across calls", "41" in resp.get("out", ""), repr(resp.get("out")))
122
+
123
+
124
+ def run_figure_checks(driver, out_dir):
125
+ resp = driver.call(
126
+ "eval",
127
+ code="close all\nfigure('Visible','off')\nplot(1:5, [1 4 9 16 25], 'o-')\ntitle('selftest')\n",
128
+ )
129
+ check("a plot can be created", resp.get("ok") is True, repr(resp.get("err")))
130
+
131
+ resp = driver.call("figure", action="list")
132
+ figures = resp.get("figures", [])
133
+ check("figure list finds the open figure", resp.get("ok") is True and len(figures) == 1, repr(resp))
134
+
135
+ resp = driver.call("figure", action="save", dir=out_dir)
136
+ figures = resp.get("figures", [])
137
+ check("figure save reports one path", resp.get("ok") is True and len(figures) == 1, repr(resp))
138
+
139
+ path = figures[0].get("path") if figures else None
140
+ check("saved figure has no error", bool(figures) and figures[0].get("error") == "", repr(figures))
141
+ check("exported PNG exists", bool(path) and os.path.isfile(path), repr(path))
142
+ size = os.path.getsize(path) if path and os.path.isfile(path) else 0
143
+ check("exported PNG is not empty", size > 1000, "%d bytes" % size)
144
+
145
+ magic = b""
146
+ if path and os.path.isfile(path):
147
+ with open(path, "rb") as handle:
148
+ magic = handle.read(8)
149
+ check("exported file is a real PNG", magic == b"\x89PNG\r\n\x1a\n", repr(magic))
150
+
151
+ resp = driver.call("figure", action="save", figure=99, dir=out_dir)
152
+ check("saving a nonexistent figure is not an error",
153
+ resp.get("ok") is True and resp.get("figures") == [], repr(resp))
154
+
155
+ resp = driver.call("figure", action="close")
156
+ check("close succeeds", resp.get("ok") is True, repr(resp))
157
+
158
+ resp = driver.call("figure", action="list")
159
+ check("no figures remain after close", resp.get("ok") is True and resp.get("figures") == [], repr(resp))
160
+
161
+ resp = driver.call("figure", action="nonsense")
162
+ check("an invalid figure action is rejected", resp.get("ok") is False, repr(resp))
163
+
164
+
165
+ def run_debug_checks(driver, fixture_dir):
166
+ driver.call("eval", code="addpath('%s')" % fixture_dir.replace("\\", "\\\\"))
167
+
168
+ resp = driver.call("debug", action="break", file="bridge_fixture", line=4)
169
+ check("breakpoint is set", resp.get("ok") is True, repr(resp))
170
+
171
+ resp = driver.call("debug", action="run", code="bridge_fixture", waitMs=15000)
172
+ check("run pauses at the breakpoint", resp.get("state") == "paused", repr(resp.get("state")))
173
+
174
+ resp = driver.call("debug", action="status")
175
+ check("status reports paused", resp.get("paused") is True, repr(resp))
176
+
177
+ resp = driver.call("debug", action="vars")
178
+ out = resp.get("out", "")
179
+ check("paused frame exposes locals", "a" in out and "b" in out, repr(out))
180
+
181
+ resp = driver.call("debug", action="get", name="a")
182
+ check("reads a local by name", resp.get("ok") is True and resp.get("out", "").startswith("1"), repr(resp))
183
+
184
+ resp = driver.call("debug", action="get", name="c")
185
+ check("unassigned local is reported as an error", resp.get("ok") is False, repr(resp))
186
+
187
+ resp = driver.call("debug", action="step")
188
+ check("step keeps the session paused", resp.get("state") == "paused", repr(resp.get("state")))
189
+
190
+ resp = driver.call("debug", action="get", name="c")
191
+ check("stepping makes the next local visible", resp.get("ok") is True and resp.get("out", "").startswith("3"), repr(resp))
192
+
193
+ resp = driver.call("debug", action="eval", code="a + b")
194
+ check("evaluates an expression in the paused frame", resp.get("ok") is True and resp.get("out", "").startswith("3"), repr(resp))
195
+
196
+ resp = driver.call("debug", action="stack")
197
+ check("stack is available while paused", resp.get("ok") is True and bool(resp.get("out")), repr(resp.get("out")))
198
+
199
+ resp = driver.call("debug", action="continue")
200
+ check("continue completes the run", resp.get("state") == "completed", repr(resp))
201
+
202
+ resp = driver.call("debug", action="status")
203
+ check("status reports not paused after continue", resp.get("paused") is False, repr(resp))
204
+
205
+ resp = driver.call("debug", action="step")
206
+ check("stepping outside a pause is refused cleanly", resp.get("ok") is False, repr(resp))
207
+
208
+
209
+ def main():
210
+ parser = argparse.ArgumentParser()
211
+ parser.add_argument("--no-debug", action="store_true", help="skip checks that start MATLAB")
212
+ args = parser.parse_args()
213
+
214
+ if not os.path.isdir(PYLIBS):
215
+ print("engine libs missing at %s -- run setup_engine.ps1 first" % PYLIBS, flush=True)
216
+ return 1
217
+
218
+ with tempfile.TemporaryDirectory(prefix="dsh-matlab-selftest-") as tmp:
219
+ with open(os.path.join(tmp, "bridge_fixture.m"), "w", encoding="utf-8", newline="\n") as handle:
220
+ handle.write(FIXTURE)
221
+
222
+ driver = Driver()
223
+ try:
224
+ resp = driver.call("ping")
225
+ check("driver answers ping", resp.get("ok") is True, repr(resp))
226
+ check("engine is lazy before first use", resp.get("engineRunning") is False, repr(resp))
227
+
228
+ run_eval_checks(driver)
229
+ run_figure_checks(driver, tmp)
230
+ if not args.no_debug:
231
+ run_debug_checks(driver, tmp)
232
+ finally:
233
+ driver.close()
234
+
235
+ print(flush=True)
236
+ if failures:
237
+ print("SELFTEST FAILED: %d check(s): %s" % (len(failures), ", ".join(failures)), flush=True)
238
+ return 1
239
+ print("SELFTEST PASSED", flush=True)
240
+ return 0
241
+
242
+
243
+ if __name__ == "__main__":
244
+ sys.exit(main())