dsh-multimodal-node 0.0.0-stage → 0.3.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,554 @@
1
+ /**
2
+ * Instance registry + local process control.
3
+ *
4
+ * An "instance" is one ComfyUI server bound to the gateway: one GPU, one port.
5
+ * `control.type` decides what start/stop mean:
6
+ *
7
+ * none — the instance is somebody else's process; only drain/bind apply.
8
+ * spawn — this gateway owns the process: start = spawn(command), stop = kill
9
+ * the whole tree. Use it for the GPUs on this desktop.
10
+ * ssh — same, but the command runs on another machine over ssh.
11
+ */
12
+ import { spawn, spawnSync } from 'node:child_process';
13
+ import { existsSync } from 'node:fs';
14
+ import { dirname } from 'node:path';
15
+ import { probe } from './comfy.js';
16
+
17
+ const ID_PATTERN = /^[a-z0-9_-]{1,32}$/;
18
+
19
+ export function normalizeBaseUrl(raw) {
20
+ let text = String(raw ?? '').trim();
21
+ if (text === '') return '';
22
+ if (!/^https?:\/\//i.test(text)) text = `http://${text}`;
23
+ try {
24
+ const url = new URL(text);
25
+ return `${url.protocol}//${url.host}`;
26
+ } catch {
27
+ return '';
28
+ }
29
+ }
30
+
31
+ export function normalizeInstance(input, index = 0) {
32
+ const baseUrl = normalizeBaseUrl(input.baseUrl ?? input.url);
33
+ const derived = baseUrl === '' ? `p${index}` : `p${new URL(baseUrl).port || '80'}`;
34
+ const id = typeof input.id === 'string' && ID_PATTERN.test(input.id) ? input.id : derived;
35
+ const control = input.control ?? {};
36
+ /**
37
+ * Two types only, and the decision is "does a bat file exist?" — not which of
38
+ * several launch mechanisms to learn.
39
+ *
40
+ * script — the gateway runs `<batPath>`; stopping kills the whole tree.
41
+ * none — somebody else owns the process; only dispatch applies.
42
+ *
43
+ * `type: 'ssh'` and free-form `command` are retired. Migration is deliberately
44
+ * conservative, because the alternative is silently launching something the
45
+ * operator can no longer see or edit in the panel:
46
+ *
47
+ * script + batPath → kept as-is
48
+ * spawn + *.bat|*.cmd → becomes script(batPath = that path)
49
+ * spawn + anything else → 'none', and the old command is dropped: a bare
50
+ * command line is not a file and must not be run as
51
+ * one ("python main.py" is not a path)
52
+ * ssh → 'none': a remote host cannot be started by a local
53
+ * .bat, and pretending otherwise would make the Start
54
+ * button lie
55
+ */
56
+ const isBat = (value) => /\.(bat|cmd)$/i.test(value.trim());
57
+ const declared = typeof control.batPath === 'string' ? control.batPath.trim() : '';
58
+ const legacy = typeof control.command === 'string' ? control.command.trim() : '';
59
+ const wantsScript = control.type === 'script' || control.type === 'spawn';
60
+ const batPath = isBat(declared) ? declared : wantsScript && isBat(legacy) ? legacy : '';
61
+ const type = wantsScript && batPath !== '' ? 'script' : 'none';
62
+ return {
63
+ id,
64
+ label: typeof input.label === 'string' && input.label !== '' ? input.label : id,
65
+ baseUrl,
66
+ apiKey: typeof input.apiKey === 'string' ? input.apiKey : '',
67
+ weight: clampInt(input.weight, 1, 10, 1),
68
+ maxConcurrent: clampInt(input.maxConcurrent, 0, 32, 1),
69
+ enabled: input.enabled !== false,
70
+ autoStart: input.autoStart === true,
71
+ control: {
72
+ type,
73
+ batPath: batPath,
74
+ cwd: typeof control.cwd === 'string' ? control.cwd : '',
75
+ stopTimeoutMs: clampInt(control.stopTimeoutMs, 500, 60000, 15000),
76
+ readyTimeoutMs: clampInt(control.readyTimeoutMs, 5000, 900000, 300000),
77
+ /**
78
+ * Kill this process when DSH exits. Default true: a gateway you can start but
79
+ * not stop leaks VRAM, and the next launch then fights the orphan for the port.
80
+ * Only an explicit `false` keeps it alive — that is for a box where ComfyUI is
81
+ * meant to outlive the console.
82
+ */
83
+ stopOnExit: control.stopOnExit !== false,
84
+ },
85
+ };
86
+ }
87
+
88
+ function clampInt(value, min, max, fallback) {
89
+ const number = Number(value);
90
+ if (!Number.isFinite(number)) return fallback;
91
+ return Math.min(max, Math.max(min, Math.floor(number)));
92
+ }
93
+
94
+ /**
95
+ * Which process is listening on this instance's port?
96
+ *
97
+ * Needed because a pid only exists for a process the *current* session spawned. After DSH
98
+ * restarts, ComfyUI is still running and holding VRAM, but the gateway has no handle — and
99
+ * a 停止 button that cannot find the process is a button that reports failure at a live
100
+ * process.
101
+ *
102
+ * Windows: `netstat -ano` gives the owning pid per listening socket. Elsewhere: `lsof` if
103
+ * it exists. Returns null when nothing is found, which the caller treats as "already down"
104
+ * only after confirming the port really does not answer.
105
+ */
106
+ function pidListeningOn(baseUrl) {
107
+ let port;
108
+ try {
109
+ port = new URL(baseUrl).port;
110
+ } catch {
111
+ return Promise.resolve(null);
112
+ }
113
+ if (port === '') return Promise.resolve(null);
114
+
115
+ if (process.platform === 'win32') {
116
+ return new Promise((resolve) => {
117
+ const run = spawn('netstat', ['-ano', '-p', 'tcp'], { windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] });
118
+ let out = '';
119
+ run.stdout.on('data', (chunk) => { out += String(chunk); });
120
+ run.on('error', () => resolve(null));
121
+ run.on('exit', () => {
122
+ // Lines look like: " TCP 0.0.0.0:8188 0.0.0.0:0 LISTENING 1234"
123
+ const suffix = `:${port}`;
124
+ for (const line of out.split(/\r?\n/)) {
125
+ if (!/LISTENING/i.test(line)) continue;
126
+ const parts = line.trim().split(/\s+/);
127
+ if (parts.length < 5) continue;
128
+ const local = parts[1] ?? '';
129
+ // Compare the port exactly: `:818` must not match `:8188`.
130
+ if (!local.endsWith(suffix)) continue;
131
+ const pid = Number(parts[parts.length - 1]);
132
+ if (Number.isFinite(pid) && pid > 0) {
133
+ resolve(pid);
134
+ return;
135
+ }
136
+ }
137
+ resolve(null);
138
+ });
139
+ });
140
+ }
141
+
142
+ return new Promise((resolve) => {
143
+ const run = spawn('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t'], { stdio: ['ignore', 'pipe', 'ignore'] });
144
+ let out = '';
145
+ run.stdout.on('data', (chunk) => { out += String(chunk); });
146
+ run.on('error', () => resolve(null));
147
+ run.on('exit', () => {
148
+ const pid = Number(String(out).trim().split(/\s+/)[0]);
149
+ resolve(Number.isFinite(pid) && pid > 0 ? pid : null);
150
+ });
151
+ });
152
+ }
153
+
154
+ /** `taskkill /T /F` on Windows and wait for it; harmless elsewhere. */
155
+ function taskkillTree(pid, wait) {
156
+ const run = spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' });
157
+ if (wait !== true) return Promise.resolve();
158
+ return new Promise((resolve) => {
159
+ let settled = false;
160
+ const done = () => {
161
+ if (settled) return;
162
+ settled = true;
163
+ resolve();
164
+ };
165
+ run.on('exit', done);
166
+ run.on('error', done);
167
+ setTimeout(done, 5000);
168
+ });
169
+ }
170
+
171
+ /** Runtime state of one instance: health, load, and (if owned) its process. */
172
+ export class InstanceRuntime {
173
+ constructor(instance) {
174
+ this.instance = instance;
175
+ this.health = { ok: false, checkedAt: 0, version: null, devices: [], latencyMs: 0, error: 'not probed' };
176
+ this.child = null;
177
+ this.pid = null;
178
+ this.starting = false;
179
+ this.stopping = false;
180
+ this.lastError = null;
181
+ this.startedAt = null;
182
+ this.speed = 1; // seconds per cost unit, EWMA; 1 = unknown/baseline
183
+ this.speedSamples = 0;
184
+ }
185
+
186
+ get id() {
187
+ return this.instance.id;
188
+ }
189
+
190
+ /**
191
+ * Bind the process to this plugin's lifetime so a restart cannot orphan it.
192
+ *
193
+ * On Windows `child.kill()` only reaches the immediate child — a ComfyUI
194
+ * launched through a wrapper (.bat, a launcher stub, a shell) leaves its real
195
+ * process alive and the VRAM stays taken. `taskkill /T` walks the tree, so it
196
+ * must run **before** the direct kill: killing the wrapper first reparents
197
+ * the grandchild and `taskkill` then finds nothing to walk.
198
+ */
199
+ async killNow() {
200
+ const pid = this.pid;
201
+ const child = this.child;
202
+ this.pid = null;
203
+ this.child = null;
204
+ if (process.platform === 'win32' && typeof pid === 'number') {
205
+ await taskkillTree(pid, true);
206
+ }
207
+ if (child !== null) {
208
+ try {
209
+ child.kill('SIGKILL');
210
+ } catch {
211
+ // already dead
212
+ }
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Synchronous kill for shutdown, because an async kill is not a kill at all.
218
+ *
219
+ * `spawn('taskkill', …)` returns immediately and the child only does its work on the
220
+ * next event-loop turn. During host shutdown the process is already exiting: the
221
+ * taskkill we just spawned is a child of *this* process and dies with it, so the
222
+ * ComfyUI tree was never actually killed and VRAM stayed held until someone noticed.
223
+ *
224
+ * `spawnSync` blocks until taskkill has finished, which is exactly what a shutdown
225
+ * path needs. The 8s ceiling matters: a wedged process must not stop DSH from exiting.
226
+ */
227
+ killDetached() {
228
+ const pid = this.pid;
229
+ const child = this.child;
230
+ this.pid = null;
231
+ this.child = null;
232
+ if (process.platform === 'win32' && typeof pid === 'number') {
233
+ try {
234
+ spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], {
235
+ windowsHide: true,
236
+ stdio: 'ignore',
237
+ timeout: 8000,
238
+ });
239
+ } catch {
240
+ // taskkill unavailable: the direct kill below is the best effort
241
+ }
242
+ }
243
+ if (child !== null) {
244
+ try {
245
+ child.kill('SIGKILL');
246
+ } catch {
247
+ // already dead
248
+ }
249
+ }
250
+ }
251
+ }
252
+
253
+ export class InstanceManager {
254
+ /**
255
+ * @param {{getInstances:()=>any[], onChange:()=>void, sse?:object}} options
256
+ */
257
+ constructor(options) {
258
+ this.options = options;
259
+ /** @type {Map<string, InstanceRuntime>} */
260
+ this.runtimes = new Map();
261
+ this.log = [];
262
+ this.sync();
263
+ }
264
+
265
+ /** Rebuild runtimes after a settings change, keeping live processes alive. */
266
+ sync() {
267
+ const wanted = new Map();
268
+ for (const raw of this.options.getInstances()) {
269
+ const instance = normalizeInstance(raw, wanted.size);
270
+ if (instance.baseUrl === '') continue;
271
+ wanted.set(instance.id, instance);
272
+ }
273
+ for (const [id, runtime] of this.runtimes) {
274
+ if (!wanted.has(id)) {
275
+ runtime.killDetached();
276
+ this.runtimes.delete(id);
277
+ }
278
+ }
279
+ for (const [id, instance] of wanted) {
280
+ const existing = this.runtimes.get(id);
281
+ if (existing === undefined) {
282
+ this.runtimes.set(id, new InstanceRuntime(instance));
283
+ } else {
284
+ existing.instance = instance;
285
+ }
286
+ }
287
+ }
288
+
289
+ list() {
290
+ return [...this.runtimes.values()];
291
+ }
292
+
293
+ get(id) {
294
+ return this.runtimes.get(id);
295
+ }
296
+
297
+ require(id) {
298
+ const runtime = this.runtimes.get(id);
299
+ if (runtime === undefined) throw new Error(`unknown instance ${id}`);
300
+ return runtime;
301
+ }
302
+
303
+ enabled() {
304
+ return this.list().filter((runtime) => runtime.instance.enabled && runtime.instance.baseUrl !== '');
305
+ }
306
+
307
+ record(level, message) {
308
+ const entry = { ts: new Date().toISOString(), level, message };
309
+ this.log.unshift(entry);
310
+ if (this.log.length > 300) this.log.length = 300;
311
+ }
312
+
313
+ /** Probe every instance in parallel; health feeds the scheduler. */
314
+ async probeAll(force = false) {
315
+ const now = Date.now();
316
+ const jobs = this.list().map(async (runtime) => {
317
+ if (!force && now - runtime.health.checkedAt < 4000) return;
318
+ const result = await probe({
319
+ id: runtime.id,
320
+ baseUrl: runtime.instance.baseUrl,
321
+ apiKey: runtime.instance.apiKey,
322
+ });
323
+ runtime.health = {
324
+ ok: result.ok,
325
+ checkedAt: Date.now(),
326
+ version: result.version,
327
+ devices: result.devices,
328
+ latencyMs: result.latencyMs,
329
+ error: result.error,
330
+ argv: result.argv ?? [],
331
+ };
332
+ if (result.ok) {
333
+ runtime.lastError = null;
334
+ if (runtime.startedAt === null) runtime.startedAt = Date.now();
335
+ } else if (!runtime.starting) {
336
+ runtime.lastError = result.error;
337
+ }
338
+ });
339
+ await Promise.all(jobs);
340
+ }
341
+
342
+ async start(id) {
343
+ const runtime = this.require(id);
344
+ if (runtime.instance.control.type === 'none') {
345
+ throw new Error(`${id} 什么都没配(control.type=none):在面板里给它选一个 bat 文件才能启停`);
346
+ }
347
+ if (runtime.starting) return { ok: true, alreadyStarting: true };
348
+ runtime.starting = true;
349
+ runtime.lastError = null;
350
+ this.record('info', `starting ${id}`);
351
+ try {
352
+ this.spawnScript(runtime);
353
+ const ready = await this.waitReady(runtime);
354
+ runtime.starting = false;
355
+ if (!ready.ok) {
356
+ runtime.lastError = ready.error;
357
+ this.record('error', `${id} start failed: ${ready.error}`);
358
+ } else {
359
+ this.record('info', `${id} ready (${ready.latencyMs} ms)`);
360
+ }
361
+ return ready;
362
+ } catch (error) {
363
+ runtime.starting = false;
364
+ runtime.lastError = error instanceof Error ? error.message : String(error);
365
+ this.record('error', `${id} start error: ${runtime.lastError}`);
366
+ return { ok: false, error: runtime.lastError };
367
+ } finally {
368
+ this.options.onChange();
369
+ }
370
+ }
371
+
372
+ /**
373
+ * Run the configured `.bat` through cmd.exe.
374
+ *
375
+ * Why cmd.exe and not `shell: true`: Node's `shell: true` picks `%ComSpec%`,
376
+ * and a path with spaces or non-ASCII characters gets mangled on the way in.
377
+ * Handing cmd.exe the literal `/d /s /c ""C:\某 目录\启动.bat""` form is the one
378
+ * quoting that survives both. `/d` also skips AutoRun, so a stray registry
379
+ * AutoRun entry cannot prepend commands to our launch.
380
+ *
381
+ * The bat is executed, not parsed: whatever it does (cd, activate a venv, start
382
+ * python with the right port) is the operator's business, which is the whole
383
+ * point of naming a file instead of typing a command.
384
+ */
385
+ spawnScript(runtime) {
386
+ const { batPath, cwd } = runtime.instance.control;
387
+ const file = batPath.trim();
388
+ if (file === '') throw new Error('没有指定 bat 文件');
389
+ if (!existsSync(file)) throw new Error(`bat 文件不存在: ${file}`);
390
+ if (!/\.(bat|cmd)$/i.test(file)) throw new Error(`不是 .bat / .cmd 文件: ${file}`);
391
+ if (cwd !== '' && !existsSync(cwd)) throw new Error(`工作目录不存在: ${cwd}`);
392
+ // cmd /s /c needs one extra layer of quotes around the whole command when the
393
+ // path may contain spaces: `cmd /d /s /c ""C:\some dir\start.bat""`.
394
+ //
395
+ // `windowsVerbatimArguments` is not optional here. Without it Node re-escapes
396
+ // the argument list for CreateProcess and cmd receives literal \" — which it
397
+ // then tries to execute as a program name ("'\"C:\...cmd\"' 不是内部或外部命令").
398
+ const child = spawn(process.env.ComSpec ?? 'cmd.exe', ['/d', '/s', '/c', `""${file}""`], {
399
+ // A bat that does not cd for itself still finds its own folder first.
400
+ cwd: cwd === '' ? dirname(file) : cwd,
401
+ shell: false,
402
+ windowsHide: true,
403
+ detached: false,
404
+ windowsVerbatimArguments: true,
405
+ stdio: ['ignore', 'pipe', 'pipe'],
406
+ env: { ...process.env, PYTHONUNBUFFERED: '1' },
407
+ });
408
+ runtime.child = child;
409
+ runtime.pid = child.pid ?? null;
410
+ const tail = [];
411
+ const keep = (chunk) => {
412
+ tail.push(String(chunk));
413
+ if (tail.length > 40) tail.shift();
414
+ runtime.consoleTail = tail.join('');
415
+ };
416
+ child.stdout?.on('data', keep);
417
+ child.stderr?.on('data', keep);
418
+ child.on('exit', (code, signal) => {
419
+ this.record('warn', `${runtime.id} exited code=${code} signal=${signal}`);
420
+ runtime.child = null;
421
+ runtime.pid = null;
422
+ runtime.health = { ...runtime.health, ok: false, checkedAt: Date.now(), error: 'process exited' };
423
+ this.options.onChange();
424
+ });
425
+ }
426
+
427
+ /** Poll /system_stats until the freshly spawned server answers. */
428
+ async waitReady(runtime) {
429
+ const deadline = Date.now() + runtime.instance.control.readyTimeoutMs;
430
+ let last = 'timeout';
431
+ while (Date.now() < deadline) {
432
+ const result = await probe({
433
+ id: runtime.id,
434
+ baseUrl: runtime.instance.baseUrl,
435
+ apiKey: runtime.instance.apiKey,
436
+ }, 3000);
437
+ if (result.ok) {
438
+ runtime.health = {
439
+ ok: true,
440
+ checkedAt: Date.now(),
441
+ version: result.version,
442
+ devices: result.devices,
443
+ latencyMs: result.latencyMs,
444
+ error: null,
445
+ argv: result.argv ?? [],
446
+ };
447
+ return { ok: true, latencyMs: result.latencyMs, version: result.version };
448
+ }
449
+ last = result.error ?? 'no answer';
450
+ if (runtime.child === null && runtime.pid === null && runtime.instance.control.type === 'script') {
451
+ const hint = (runtime.consoleTail ?? '').split('\n').slice(-4).join(' | ');
452
+ return { ok: false, error: `进程已退出: ${hint || last}` };
453
+ }
454
+ await new Promise((resolve) => setTimeout(resolve, 1500));
455
+ }
456
+ return { ok: false, error: `等待就绪超时: ${last}` };
457
+ }
458
+
459
+ async stop(id) {
460
+ const runtime = this.require(id);
461
+ if (runtime.instance.control.type === 'none') {
462
+ throw new Error(`${id} 不受本网关托管,只能 drain(停止派发),不能停进程`);
463
+ }
464
+ runtime.stopping = true;
465
+ this.record('info', `stopping ${id}`);
466
+ try {
467
+ /**
468
+ * No pid? Find the process before deciding there is nothing to stop.
469
+ *
470
+ * `this.pid` only exists for a process THIS session spawned. After a DSH
471
+ * restart the ComfyUI tree is still running (it outlived the console) but the
472
+ * gateway has no handle on it, so the old code fell straight through to the
473
+ * poll loop, waited the full 15 s and reported "端口仍在响应" — while the
474
+ * process it was asked to stop sat there untouched. That is the "stop doesn't
475
+ * work" report: a button that waits 15 seconds and fails.
476
+ *
477
+ * Recovering the pid means asking the OS which process owns the port, then
478
+ * killing that tree. It is the same authority the user expects the button to
479
+ * have; guessing nothing, verifying by port ownership.
480
+ */
481
+ let pid = runtime.pid;
482
+ if (typeof pid !== 'number') {
483
+ pid = await pidListeningOn(runtime.instance.baseUrl);
484
+ if (typeof pid === 'number') {
485
+ this.record('info', `${id}: recovered pid ${pid} from the port`);
486
+ }
487
+ }
488
+ if (typeof pid !== 'number') {
489
+ // Nothing is listening on that port at all: the instance is already down,
490
+ // which is what "stop" was asking for. Reporting failure here was wrong.
491
+ const gone = await probe({ id, baseUrl: runtime.instance.baseUrl, apiKey: runtime.instance.apiKey }, 1500);
492
+ if (!gone.ok) {
493
+ runtime.health = { ...runtime.health, ok: false, checkedAt: Date.now(), error: 'stopped' };
494
+ this.record('info', `${id}: nothing listening, treated as stopped`);
495
+ return { ok: true, stopped: true, note: '端口本来就没有服务' };
496
+ }
497
+ return { ok: false, error: '找不到该端口的进程,也无法确认它已停止' };
498
+ }
499
+
500
+ await runtime.killNow();
501
+ if (runtime.pid === null && typeof pid === 'number') {
502
+ // killNow only acts on its own handle; make sure a recovered pid dies too.
503
+ await taskkillTree(pid, true);
504
+ }
505
+ // A wrapper script may keep the port open for a moment; poll the port.
506
+ const deadline = Date.now() + runtime.instance.control.stopTimeoutMs;
507
+ while (Date.now() < deadline) {
508
+ const result = await probe({ id, baseUrl: runtime.instance.baseUrl, apiKey: runtime.instance.apiKey }, 1500);
509
+ if (!result.ok) {
510
+ runtime.health = { ...runtime.health, ok: false, checkedAt: Date.now(), error: 'stopped' };
511
+ this.record('info', `${id} stopped`);
512
+ return { ok: true, stopped: true };
513
+ }
514
+ await new Promise((resolve) => setTimeout(resolve, 700));
515
+ }
516
+ return { ok: false, error: '进程未在超时内退出(端口仍在响应)' };
517
+ } finally {
518
+ runtime.stopping = false;
519
+ this.options.onChange();
520
+ }
521
+ }
522
+
523
+ async restart(id) {
524
+ const stopped = await this.stop(id);
525
+ if (!stopped.ok) return stopped;
526
+ return this.start(id);
527
+ }
528
+
529
+ /**
530
+ * Shutdown: take every owned process down with us.
531
+ *
532
+ * `stopOnExit` is the operator's choice, per instance. It defaults to true because the
533
+ * alternative — a gateway you can start but not stop — leaks VRAM: the process keeps
534
+ * running with nothing driving it, and the next launch fights it for the port.
535
+ *
536
+ * The summary is returned so the caller can log what actually happened. "We tried" is
537
+ * not the same as "it died", and on a shutdown path that distinction is the only thing
538
+ * worth recording.
539
+ */
540
+ dispose() {
541
+ const stopped = [];
542
+ const kept = [];
543
+ for (const runtime of this.runtimes.values()) {
544
+ if (runtime.instance.control.type === 'none') continue;
545
+ if (runtime.instance.control.stopOnExit === false) {
546
+ kept.push(runtime.id);
547
+ continue;
548
+ }
549
+ runtime.killDetached();
550
+ stopped.push(runtime.id);
551
+ }
552
+ return { stopped, kept };
553
+ }
554
+ }