@alotop/dsh-matlab-bridge 0.1.6 → 0.2.0

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.
package/README.md CHANGED
@@ -119,10 +119,11 @@ All optional; the defaults need no configuration on a normal machine.
119
119
  - id: matlab-bridge
120
120
  name: '@alotop/dsh-matlab-bridge'
121
121
  config:
122
- pythonPath: python3.12 # default: first of python3, python on PATH
123
- workDir: /path/to/project # default: the calling session's cwd
124
- figureDir: /tmp/figures # default: <workDir>/.matlab-figures
125
- timeoutMs: 180000 # default: 180000
122
+ pythonPath: python3.12 # default: first of python3, python on PATH
123
+ workDir: /path/to/project # default: the calling session's cwd
124
+ figureDir: /tmp/figures # default: <workDir>/.matlab-figures
125
+ timeoutMs: 180000 # default: 180000
126
+ shutdownEngineOnExit: true # default: true
126
127
  ```
127
128
 
128
129
  ## Tools
@@ -172,8 +173,47 @@ nothing.
172
173
  ### `matlab_session`
173
174
 
174
175
  `start`, `status`, `stop`. MATLAB takes tens of seconds to start, so the session
175
- stays warm and is reused. `status` reports the package path, whether the driver
176
- is running, and whether the engine runtime has been laid out.
176
+ stays warm and is reused.
177
+
178
+ `status` reports the package path, **the Python interpreter the driver runs on**,
179
+ whether the driver is running, whether the engine runtime has been laid out, and
180
+ whether MATLAB is quit on exit. The interpreter is worth showing on a machine
181
+ that carries several: the one that resolves first is not necessarily the one
182
+ setup verified, and `pythonPath` is the fix when they disagree.
183
+
184
+ ## Commands
185
+
186
+ Four human entry points, for the operations that do not belong on the model's
187
+ tool surface:
188
+
189
+ | Command | Does |
190
+ |---|---|
191
+ | `/matlab-status` | The same report as `matlab_session status`. |
192
+ | `/matlab-start` | Start the persistent engine session. |
193
+ | `/matlab-stop` | Quit MATLAB and stop the driver. |
194
+ | `/matlab-setup` | Lay out the engine runtime from the local MATLAB install. |
195
+
196
+ `/matlab-setup` runs the layout script through the same unconfined subprocess
197
+ seam as everything else, so it can write into the installed package directory —
198
+ which lies outside every session workspace — without you opening a separate
199
+ terminal.
200
+
201
+ ### Quitting MATLAB when DSH exits
202
+
203
+ `shutdownEngineOnExit` (default `true`) decides whether shutting DSH down also
204
+ quits MATLAB. It is on by default because the driver is a child process: killing
205
+ it outright leaves the MATLAB engine it started with no owner.
206
+
207
+ Two mechanisms cover both ways DSH can go away:
208
+
209
+ - a normal plugin disposal writes the shutdown request and closes the driver's
210
+ stdin, so the driver quits MATLAB and exits on its own;
211
+ - if DSH exits without disposing the plugin at all, that closed pipe reaches the
212
+ driver as EOF, and the driver quits MATLAB and exits anyway.
213
+
214
+ Set it to `false` to leave MATLAB running when DSH exits. Without a shared engine
215
+ session nothing can reattach to it, so it becomes an orphan process — useful for
216
+ inspecting a live session, not a way to keep one.
177
217
 
178
218
  ## How it works
179
219
 
package/README.zh-CN.md CHANGED
@@ -94,10 +94,11 @@ npm install --no-save ./alotop-dsh-matlab-bridge-<版本>.tgz # 或装进某
94
94
  - id: matlab-bridge
95
95
  name: '@alotop/dsh-matlab-bridge'
96
96
  config:
97
- pythonPath: python3.12 # 默认:PATH 上第一个 python3 / python
98
- workDir: /path/to/project # 默认:调用方会话的 cwd
99
- figureDir: /tmp/figures # 默认:<workDir>/.matlab-figures
100
- timeoutMs: 180000 # 默认:180000
97
+ pythonPath: python3.12 # 默认:PATH 上第一个 python3 / python
98
+ workDir: /path/to/project # 默认:调用方会话的 cwd
99
+ figureDir: /tmp/figures # 默认:<workDir>/.matlab-figures
100
+ timeoutMs: 180000 # 默认:180000
101
+ shutdownEngineOnExit: true # 默认:true
101
102
  ```
102
103
 
103
104
  ## 工具
@@ -132,7 +133,33 @@ matlab_debug action=continue → state: completed
132
133
 
133
134
  ### `matlab_session`
134
135
 
135
- `start`、`status`、`stop`。MATLAB 启动要几十秒,所以会话保持热态并复用。`status` 会报告包路径、驱动是否在跑、以及引擎运行库是否已经铺开。
136
+ `start`、`status`、`stop`。MATLAB 启动要几十秒,所以会话保持热态并复用。
137
+
138
+ `status` 会报告包路径、**驱动实际使用的 Python 解释器**、驱动是否在跑、引擎运行库是否已铺开、以及退出 DSH 时是否关闭 MATLAB。解释器值得显示出来——在有多个 Python 的机器上,PATH 上先解析到的那个不一定是 setup 验证过的那个,两者不一致时 `pythonPath` 就是解药。
139
+
140
+ ## 命令
141
+
142
+ 四个人工入口,用于那些不该出现在模型工具面上的操作:
143
+
144
+ | 命令 | 作用 |
145
+ |---|---|
146
+ | `/matlab-status` | 与 `matlab_session status` 同一份报告 |
147
+ | `/matlab-start` | 启动常驻引擎会话 |
148
+ | `/matlab-stop` | 关闭 MATLAB 并停止驱动 |
149
+ | `/matlab-setup` | 从本地 MATLAB 铺开引擎运行库 |
150
+
151
+ `/matlab-setup` 走的是与其它操作相同的非受限 subprocess 通道,因此它能写入**已安装的包目录**(位于任何会话工作区之外),不需要你另开终端。
152
+
153
+ ### DSH 退出时是否关闭 MATLAB
154
+
155
+ `shutdownEngineOnExit`(默认 `true`)决定关闭 DSH 时是否一并关闭 MATLAB。默认开启,因为驱动是子进程:直接杀掉它会留下一个**无人认领**的 MATLAB 引擎。
156
+
157
+ 两条机制覆盖 DSH 两种退出方式:
158
+
159
+ - 正常的插件卸载会写入关闭请求并关闭驱动的 stdin,驱动自己跑完 `engine.quit()` 后退出;
160
+ - 如果 DSH 根本没卸载插件就退出,那条管道关闭会以 **EOF** 形式到达驱动,驱动同样退出 MATLAB 后再结束。
161
+
162
+ 设为 `false` 可让 DSH 退出时保留 MATLAB。但在没有 shared engine 会话的前提下,没有任何东西能重接上去,它只会变成一个孤儿进程——适合临时查看一个活会话,不是"保留会话"的办法。
136
163
 
137
164
  ## 工作原理
138
165
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alotop/dsh-matlab-bridge",
3
- "version": "0.1.6",
3
+ "version": "0.2.0",
4
4
  "description": "Run and interactively debug MATLAB code from a DSH session, through a persistent MATLAB Engine session.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -386,6 +386,23 @@ def op_figure(req):
386
386
  return {"ok": False, "err": "unknown figure action: %s" % action}
387
387
 
388
388
 
389
+ def shutdown_engine():
390
+ """Quit the MATLAB engine if one is running, and forget it.
391
+
392
+ Quitting is the only thing that stops the MATLAB process: it was started by
393
+ this driver, and letting the driver die without this leaves MATLAB running
394
+ with no owner.
395
+ """
396
+ global _engine
397
+ if _engine is None:
398
+ return
399
+ try:
400
+ _engine.quit()
401
+ except Exception: # noqa: BLE001
402
+ pass
403
+ _engine = None
404
+
405
+
389
406
  def dispatch(req):
390
407
  global _engine
391
408
  op = req.get("op", "")
@@ -402,12 +419,7 @@ def dispatch(req):
402
419
  if op == "figure":
403
420
  return op_figure(req)
404
421
  if op == "shutdown":
405
- if _engine is not None:
406
- try:
407
- _engine.quit()
408
- except Exception: # noqa: BLE001
409
- pass
410
- _engine = None
422
+ shutdown_engine()
411
423
  emit({"id": req.get("id"), "ok": True, "state": "shutdown"})
412
424
  raise SystemExit(0)
413
425
  return {"ok": False, "err": "unknown op: %s" % op}
@@ -432,6 +444,16 @@ def main():
432
444
  response["id"] = req.get("id")
433
445
  emit(response)
434
446
 
447
+ # stdin reached EOF: the caller is gone. This is the second half of the
448
+ # clean-exit story -- a parent that exits without disposing us closes this
449
+ # pipe, so quitting MATLAB here is what keeps a closed DSH from leaving an
450
+ # orphaned MATLAB behind. The notice goes to stderr because that is the
451
+ # diagnostic channel, and it is what a self-test can observe: the process
452
+ # exits either way, so only this distinguishes "quit MATLAB first" from
453
+ # "died and left MATLAB running".
454
+ print("dsh-matlab-bridge: stdin closed, quitting MATLAB", file=sys.stderr, flush=True)
455
+ shutdown_engine()
456
+
435
457
 
436
458
  if __name__ == "__main__":
437
459
  main()
@@ -248,6 +248,8 @@ def main():
248
248
  run_figure_checks(driver, tmp)
249
249
  if not args.no_debug:
250
250
  run_debug_checks(driver, tmp)
251
+ # Last, because it ends the driver's life.
252
+ run_exit_checks(driver)
251
253
  finally:
252
254
  driver.close()
253
255
 
@@ -259,5 +261,33 @@ def main():
259
261
  return 0
260
262
 
261
263
 
264
+ def run_exit_checks(driver):
265
+ """
266
+ Closing the driver's stdin is what a DSH shutdown looks like from inside the
267
+ driver, so it must quit MATLAB and then exit on its own.
268
+
269
+ The process exits either way -- EOF ends the read loop whether or not the
270
+ cleanup runs -- so the assertion is the cleanup notice on stderr, not the
271
+ exit itself. Exiting without that notice is exactly how a MATLAB process
272
+ gets orphaned.
273
+ """
274
+ driver.proc.stdin.close()
275
+ deadline = time.time() + 90
276
+ saw_cleanup = False
277
+ while time.time() < deadline:
278
+ line = driver.proc.stdout.readline()
279
+ if line == "":
280
+ break
281
+ if "stdin closed, quitting MATLAB" in line:
282
+ saw_cleanup = True
283
+ try:
284
+ code = driver.proc.wait(timeout=60)
285
+ except Exception as exc: # noqa: BLE001
286
+ check("driver quits MATLAB when its stdin closes", False, "did not exit: %r" % (exc,))
287
+ return
288
+ check("driver quits MATLAB when its stdin closes", saw_cleanup,
289
+ "cleanup notice=%s exit=%s" % (saw_cleanup, code))
290
+
291
+
262
292
  if __name__ == "__main__":
263
293
  sys.exit(main())
package/src/plugin.mjs CHANGED
@@ -193,6 +193,123 @@ export function apply(ctx, config) {
193
193
  if (typeof cwd === 'string' && cwd.length > 0) sessionCwd = cwd
194
194
  }
195
195
 
196
+ /**
197
+ * Whether DSH shutting down also quits MATLAB. On by default: the driver is
198
+ * a child process, and killing it hard leaves the MATLAB engine it started
199
+ * with no owner.
200
+ */
201
+ const shutdownOnExit = config?.shutdownEngineOnExit !== false
202
+
203
+ function setupScriptPath() {
204
+ return join(PACKAGE_ROOT, 'scripts', 'setup-engine.mjs')
205
+ }
206
+
207
+ /**
208
+ * Report the interpreter the driver actually runs on. A machine can carry
209
+ * several (a system Python and a conda one, say), and the one that resolves
210
+ * first is not necessarily the one that was verified during setup, so seeing
211
+ * it is the difference between a five-second diagnosis and a long one.
212
+ */
213
+ async function pythonLine() {
214
+ try {
215
+ return 'python: ' + (await resolvePython())
216
+ } catch (error) {
217
+ return 'python: NOT FOUND - ' + String((error && error.message) || error)
218
+ }
219
+ }
220
+
221
+ /** Shared by the `matlab_session` tool and the /matlab-status command. */
222
+ async function statusReport() {
223
+ const runtimePresent = existsSync(join(PYLIBS_PATH, 'matlab'))
224
+ const lines = [
225
+ 'package: ' + PACKAGE_ROOT,
226
+ await pythonLine(),
227
+ 'driver: ' + (handle === null ? 'not running' : 'running'),
228
+ 'engine runtime: ' + (runtimePresent ? 'present' : 'MISSING'),
229
+ 'quit MATLAB on exit: ' + (shutdownOnExit ? 'yes' : 'no'),
230
+ ]
231
+ if (!runtimePresent) {
232
+ lines.push('', 'Lay it out with /matlab-setup, or: node ' + JSON.stringify(setupScriptPath()))
233
+ }
234
+ if (handle === null) {
235
+ lines.push('matlab: not started')
236
+ return lines.join('\n')
237
+ }
238
+ const resp = await rpc('ping', {}, 30000)
239
+ lines.push('matlab: ' + (resp.engineRunning ? 'running' : 'not started'))
240
+ return lines.join('\n')
241
+ }
242
+
243
+ /** Shared by the `matlab_session` tool and the /matlab-stop command. */
244
+ async function stopEngine() {
245
+ if (handle === null) return 'no MATLAB session is running'
246
+ try {
247
+ await rpc('shutdown', {}, 60000)
248
+ } catch {
249
+ // The driver exits as part of shutdown, so a dropped reply is expected.
250
+ }
251
+ try {
252
+ handle.terminate()
253
+ } catch {
254
+ // Already gone.
255
+ }
256
+ handle = null
257
+ pending.clear()
258
+ return 'MATLAB session stopped'
259
+ }
260
+
261
+ /** Shared by the `matlab_session` tool and the /matlab-start command. */
262
+ async function startEngine() {
263
+ const resp = await rpc('start', {}, timeoutMs)
264
+ if (resp.ok === false) return 'MATLAB failed to start: ' + resp.err
265
+ return 'MATLAB ' + (resp.version || '') + ' started'
266
+ }
267
+
268
+ /** Keep a command reply readable; setup output is long and mostly progress. */
269
+ function tailLines(text, max) {
270
+ const lines = text.split('\n').filter((line) => line.trim() !== '')
271
+ return lines.length <= max ? lines.join('\n') : lines.slice(-max).join('\n')
272
+ }
273
+
274
+ /**
275
+ * Run the engine-layout script. Spawning it through `ctx.subprocess` matters:
276
+ * the script writes into this package's own directory, which lies outside the
277
+ * session workspace, and only this unconfined seam can write there — so
278
+ * /matlab-setup works without the user opening a separate terminal.
279
+ * `process.execPath` is the Node already running DSH, so no PATH lookup.
280
+ */
281
+ async function runSetup() {
282
+ const script = setupScriptPath()
283
+ if (!existsSync(script)) throw new Error('setup script not found at ' + script)
284
+ const spawned = ctx.subprocess.spawn({
285
+ argv: [process.execPath, script],
286
+ cwd: PACKAGE_ROOT,
287
+ stdio: {
288
+ stdin: 'ignore',
289
+ stdout: { maxBytes: 64000 },
290
+ stderr: { maxBytes: 64000 },
291
+ },
292
+ graceMs: 10000,
293
+ })
294
+ const outcome = await spawned.done
295
+ const collected = spawned.collected ?? {}
296
+ const read = (reader) => {
297
+ try {
298
+ return reader === undefined ? '' : reader.readFrom(0).text
299
+ } catch {
300
+ return ''
301
+ }
302
+ }
303
+ const text = [read(collected.stdout), read(collected.stderr)]
304
+ .filter((part) => part.length > 0)
305
+ .join('\n')
306
+ const tail = tailLines(text, 20)
307
+ if (outcome.exitCode !== 0) {
308
+ return 'setup-engine failed (exit code ' + outcome.exitCode + ')\n' + (tail || '(no output)')
309
+ }
310
+ return tail || 'setup-engine finished with no output'
311
+ }
312
+
196
313
  function pushChatter(line) {
197
314
  if (line.trim() === '') return
198
315
  chatter.push(line)
@@ -451,61 +568,69 @@ export function apply(ctx, config) {
451
568
  async execute(args, exec) {
452
569
  rememberSession(exec)
453
570
  const action = String(args.action)
454
- if (action === 'stop') {
455
- if (handle === null) return { text: 'no MATLAB session is running' }
456
- try {
457
- await rpc('shutdown', {}, 60000)
458
- } catch {
459
- // The driver exits as part of shutdown, so a dropped reply is expected.
460
- }
461
- try {
462
- handle.terminate()
463
- } catch {
464
- // Already gone.
465
- }
466
- handle = null
467
- pending.clear()
468
- return { text: 'MATLAB session stopped' }
469
- }
470
- if (action === 'status') {
471
- const runtimePresent = existsSync(join(PYLIBS_PATH, 'matlab'))
472
- const lines = [
473
- 'package: ' + PACKAGE_ROOT,
474
- 'driver: ' + (handle === null ? 'not running' : 'running'),
475
- // Name the exact command rather than a bare bin name: this package is
476
- // normally installed into a DSH profile, where its bin is not on
477
- // PATH, so "run dsh-matlab-bridge-setup" is not actionable.
478
- 'engine runtime: ' + (runtimePresent
479
- ? 'present'
480
- : 'MISSING - run: node ' + JSON.stringify(join(PACKAGE_ROOT, 'scripts', 'setup-engine.mjs'))),
481
- ]
482
- if (handle === null) {
483
- lines.push('matlab: not started')
484
- return { text: lines.join('\n') }
485
- }
486
- const resp = await rpc('ping', {}, 30000)
487
- lines.push('matlab: ' + (resp.engineRunning ? 'running' : 'not started'))
488
- return { text: lines.join('\n') }
489
- }
490
- if (action === 'start') {
491
- const resp = await rpc('start', {}, timeoutMs)
492
- if (resp.ok === false) return { text: 'MATLAB failed to start: ' + resp.err }
493
- return { text: 'MATLAB ' + (resp.version || '') + ' started' }
494
- }
571
+ if (action === 'stop') return { text: await stopEngine() }
572
+ if (action === 'status') return { text: await statusReport() }
573
+ if (action === 'start') return { text: await startEngine() }
495
574
  return { text: 'unknown action: ' + action + ' (use start, status or stop)' }
496
575
  },
497
576
  })
498
577
 
578
+ /**
579
+ * Ask the driver to quit MATLAB, then close its stdin.
580
+ *
581
+ * The disposer cannot await, and killing the driver outright would orphan the
582
+ * MATLAB process it started, because a hard kill never runs Python's atexit.
583
+ * Writing the shutdown request and closing the pipe hands the clean exit to
584
+ * the driver instead. Returns false when there is no usable stdin, so the
585
+ * caller can fall back to terminate().
586
+ */
587
+ function requestGracefulStop(active) {
588
+ try {
589
+ active.stdin.write(JSON.stringify({ id: nextId, op: 'shutdown' }) + '\n')
590
+ nextId += 1
591
+ active.stdin.end()
592
+ return true
593
+ } catch {
594
+ return false
595
+ }
596
+ }
597
+
598
+ /**
599
+ * Human entry points for the operations that do not belong on the model's
600
+ * tool surface. `commands` is read optionally rather than injected: a
601
+ * deployment without a command registry must still get the tools, which are
602
+ * this plugin's whole value.
603
+ */
604
+ const commands = ctx.get('commands')
605
+ if (commands !== undefined) {
606
+ const command = (name, description, run) => ctx.effect(() => commands.register({
607
+ name,
608
+ description,
609
+ handler: async () => {
610
+ try {
611
+ return { kind: 'success', text: await run() }
612
+ } catch (error) {
613
+ return { kind: 'error', text: String((error && error.message) || error) }
614
+ }
615
+ },
616
+ }))
617
+ command('matlab-status', 'Show MATLAB bridge status: package, interpreter, driver, engine runtime.', statusReport)
618
+ command('matlab-start', 'Start the persistent MATLAB engine session.', startEngine)
619
+ command('matlab-stop', 'Shut down the persistent MATLAB engine session and its driver.', stopEngine)
620
+ command('matlab-setup', 'Lay out the MATLAB Engine runtime from the local MATLAB installation.', runSetup)
621
+ }
622
+
499
623
  // The driver owns a MATLAB process; the mount must not leak it.
500
624
  ctx.effect(() => () => {
501
- if (handle !== null) {
502
- try {
503
- handle.terminate()
504
- } catch {
505
- // Nothing left to release.
506
- }
507
- handle = null
508
- }
625
+ const active = handle
626
+ handle = null
509
627
  failAll('matlab-bridge plugin was stopped')
628
+ if (active === null) return
629
+ if (shutdownOnExit && requestGracefulStop(active)) return
630
+ try {
631
+ active.terminate()
632
+ } catch {
633
+ // Nothing left to release.
634
+ }
510
635
  })
511
636
  }