dsh-multimodal-node 0.0.0-stage → 0.2.2
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 +504 -2
- package/client.js +2817 -0
- package/cordis.patch.yml +4 -0
- package/icon.svg +9 -0
- package/index.js +960 -0
- package/lib/apikeys.js +250 -0
- package/lib/assets.js +234 -0
- package/lib/auth.js +315 -0
- package/lib/blender.js +600 -0
- package/lib/chain.js +161 -0
- package/lib/comfy.js +204 -0
- package/lib/config.js +377 -0
- package/lib/gateway.js +974 -0
- package/lib/http.js +119 -0
- package/lib/imagesize.js +92 -0
- package/lib/instances.js +554 -0
- package/lib/library.js +1209 -0
- package/lib/models.js +322 -0
- package/lib/nodeapi.js +491 -0
- package/lib/nodeclient.js +143 -0
- package/lib/pages.js +5788 -0
- package/lib/pool.js +591 -0
- package/lib/promptassist.js +1077 -0
- package/lib/promptlibrary.js +92 -0
- package/lib/remote.js +3348 -0
- package/lib/runindex.js +126 -0
- package/lib/seed.js +118 -0
- package/lib/tagdata.json +1 -0
- package/lib/tiers.js +283 -0
- package/lib/tools.js +230 -0
- package/lib/viewport.js +134 -0
- package/lib/workflows.js +472 -0
- package/lib/ws.js +106 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +60 -4
package/lib/instances.js
ADDED
|
@@ -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
|
+
}
|