Repository navigation
Expand file tree
/
Copy pathcomfyProcess.js
More file actions
524 lines (503 loc) · 28.2 KB
/
Copy pathcomfyProcess.js
File metadata and controls
524 lines (503 loc) · 28.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
const { spawn, execFile } = require('child_process');
const { promisify } = require('util');
const fs = require('fs');
const path = require('path');
const EventEmitter = require('events');
const axios = require('axios');
const execFileAsync = promisify(execFile);
const launcher = require('./comfyLauncher');
// How long to give a ComfyUI that holds the port but isn't answering yet (see
// start()). A cold ComfyUI on this class of machine needs 30–90 s.
const PORT_SETTLE_MS = 90000;
// How long a ComfyUI that ComfyQ started may take to answer before it is
// presumed hung and replaced (see start()). A cold boot on this rig takes
// 15–40 s; the first boot of a freshly cloned drive can take minutes.
const BOOT_PATIENCE_MS = 10 * 60 * 1000;
// Owns the ComfyUI process lifecycle. v2 design: spawn once, keep alive.
// If ComfyUI exits, we emit 'exited' and let the LocalComfyUIWorker decide
// whether to respawn (default: yes, with backoff).
//
// ★ ComfyUI is started so that it OUTLIVES ComfyQ (see comfyLauncher.js for
// the measurements behind this): a ComfyQ restart — switching workflow,
// stopping serving, a crash, a code reload — no longer costs a cold ComfyUI
// boot and a model reload. The next ComfyQ finds its own ComfyUI in
// .comfyq-lanes/processes.json and ADOPTS it: `owned` stays true, so it can
// still be stopped, flag-masked and respawned. A ComfyUI nobody recorded (one
// launched from the .bat) is still treated as external and left alone.
class ComfyProcess extends EventEmitter {
constructor({ rootPath, pythonExecutable, host, bindHost, port, installationType, onMilestone,
useSageAttention = false, fp16Accumulation = false,
userDir = null, tempDir = null, vramHeadroomGb = null, managerOffline = true }) {
super();
this.rootPath = rootPath;
this.pythonExecutable = pythonExecutable;
// `host` is the address ComfyQ *connects* to (always localhost-reachable).
// `bindHost` is what ComfyUI *listens* on via --listen: 0.0.0.0 to expose
// the native ComfyUI UI to the LAN, else the same loopback as `host`.
this.host = host;
this.bindHost = bindHost || host;
this.port = port;
this.installationType = installationType || 'portable';
// Opt-in performance flags (config.comfy_ui.*). Global — they apply to
// every workflow this backend runs, so they are off unless an admin
// turns them on.
this.useSageAttention = !!useSageAttention;
this.fp16Accumulation = !!fp16Accumulation;
// Per-lane state directories. Null means "use the install's own", which
// is what a single-lane machine does — byte-identical to before.
this.userDir = userDir || null;
this.tempDir = tempDir || null;
// GB this instance keeps free for the others sharing the card.
this.vramHeadroomGb = vramHeadroomGb || null;
// Keep ComfyUI-Manager from fetching from the internet at start (see spawn).
this.managerOffline = managerOffline !== false;
// The PID of the ComfyUI we own — spawned by this ComfyQ or adopted
// from a previous one. Null for an external instance.
this.pid = null;
// When that PID became ours (launched or adopted) — the clock
// BOOT_PATIENCE_MS runs against.
this._ownedSince = null;
this._tails = [];
this._watch = null;
// Why the next start() happens, for the restart history the admin
// panel shows ("crashed", "perf-flags", "sage-incompatible", …).
this.nextStartReason = 'start';
// Extra arguments for main.py (tests use --cpu on a throwaway port).
this.extraArgs = [];
// Boot-milestone callback (see LocalComfyUIWorker). Used here to
// reprint the LAN URL banner the moment ComfyUI's comfyregistry
// fetch finishes — that's the natural "everything is ready" point
// and the last big block of startup noise. Defaults to no-op.
this.onMilestone = onMilestone || (() => {});
this._registryMilestoneFired = false;
// Line buffer for stdout. Python flushes can split a single line
// across two 'data' events, so a regex against each chunk alone
// would miss matches that straddle the boundary.
this._stdoutBuf = '';
}
validate() {
if (!fs.existsSync(this.pythonExecutable)) {
throw new Error(`Python executable not found: ${this.pythonExecutable}`);
}
if (!fs.existsSync(this.rootPath)) {
throw new Error(`ComfyUI root path not found: ${this.rootPath}`);
}
if (!fs.existsSync(path.join(this.rootPath, 'main.py'))) {
throw new Error(`ComfyUI main.py not found in ${this.rootPath}`);
}
}
/** True when this ComfyUI is ours to stop, mask and respawn. */
get owned() { return !!this.pid && launcher.pidAlive(this.pid); }
_flags() {
return { use_sage_attention: this.useSageAttention, fp16_accumulation: this.fp16Accumulation };
}
// Is the ComfyUI answering on our port one a ComfyQ started — the PID we
// recorded for this port is alive, is the process LISTENING there, and was
// started for the same user directory (a lane's ComfyUI is never adopted
// as the install's, or the other way round)? Then take it back.
async _tryAdopt() {
const rec = launcher.recordFor(this.port);
if (!rec || !launcher.pidAlive(rec.pid)) return false;
if (launcher.norm(rec.userDir) !== launcher.norm(this.userDir)) return false;
const listening = await launcher.pidsOnPort(this.port);
if (!listening.includes(rec.pid)) return false;
// Already ours — a retry of start() found the ComfyUI this ComfyQ
// launched or adopted itself: nothing to take back, and no 'adopt' to
// put in the restart history (it would say ComfyQ had restarted).
if (this.pid === rec.pid) {
this.adoptedFlags = rec.flags || null;
return true;
}
this.pid = rec.pid;
this._ownedSince = Date.now();
this.adoptedFlags = rec.flags || null;
this._follow(true);
this._watchPid();
launcher.recordEvent({ port: this.port, pid: rec.pid, event: 'adopt', reason: 'ComfyQ restarted; the ComfyUI it had started was still running' });
console.log(`[ComfyProcess] Adopted the ComfyUI this machine's ComfyQ started earlier (PID ${rec.pid}, port ${this.port}) — no restart needed.`);
return true;
}
// Follow the log files and pass them through to ComfyQ's own terminal,
// with the milestone scan, the way the stdout pipe used to be.
_follow(fromEnd) {
this._unfollow();
const base = launcher.logBaseFor(this.port);
this._tails = [
new launcher.LogTail(`${base}.out.log`, d => this._handleStdoutChunk(d), { fromEnd }),
new launcher.LogTail(`${base}.err.log`, d => process.stderr.write(`[ComfyUI!] ${d}`), { fromEnd }),
];
}
_unfollow() {
for (const t of this._tails) { try { t.stop(); } catch { /* ignore */ } }
this._tails = [];
}
// There is no child handle to emit 'exit' (the process is not our child),
// so poll the PID. 'exited' means the same as before.
_watchPid() {
if (this._watch) clearInterval(this._watch);
const pid = this.pid;
this._watch = setInterval(() => {
if (launcher.pidAlive(pid)) return;
clearInterval(this._watch);
this._watch = null;
if (this.pid !== pid) return;
this.pid = null;
this._ownedSince = null;
this._unfollow();
const intentional = !!this._stopping;
this._stopping = false;
launcher.recordEvent({ port: this.port, pid, event: 'exit', reason: intentional ? 'stopped by ComfyQ' : 'exited on its own' });
console.log(`[ComfyProcess] ComfyUI (PID ${pid}) exited${intentional ? '' : ' unexpectedly'}`);
this.emit('exited', { code: null, signal: null, intentional });
}, 2000);
if (this._watch.unref) this._watch.unref();
}
// The same race one ComfyQ later: ComfyQ restarted (a nodemon reload, a
// workflow switch) while the ComfyUI it had just started was still
// booting. Nothing answers and the port is not bound yet, but the registry
// holds that start, for this port and this user directory, with its PID
// alive. Wait for it to come up, so _tryAdopt can take it back, instead of
// launching a competitor. Returns true once it answers. A record older
// than BOOT_PATIENCE_MS is not waited for: that PID may by now belong to
// some other program, so it is neither trusted nor stopped.
async _waitForRecordedBoot() {
const rec = launcher.recordFor(this.port);
const since = rec ? Date.parse(rec.startedAt) : NaN;
if (!rec || !Number.isFinite(since) || launcher.norm(rec.userDir) !== launcher.norm(this.userDir)) return false;
const booting = () => launcher.pidAlive(rec.pid) && Date.now() - since < BOOT_PATIENCE_MS;
if (!booting()) return false;
console.log(`[ComfyProcess] The ComfyUI a previous ComfyQ started on port ${this.port} (PID ${rec.pid}) is still starting — waiting for it.`);
while (booting()) {
if (await this.isApiResponsive()) return true;
await new Promise(r => setTimeout(r, this._pollMs || 2000));
}
return false;
}
async isApiResponsive() {
try {
await axios.get(`http://${this.host}:${this.port}/system_stats`, { timeout: 1500 });
return true;
} catch { return false; }
}
// Is anything LISTENING on the port, answering the API or not? A ComfyUI
// that is still importing torch, or busy loading a 20 GB model, does not
// reply within the API probe's 1.5 s — but it does hold the port, so
// spawning our own would produce a process that cannot bind.
async isPortBound() {
const net = require('net');
return new Promise(resolve => {
const sock = net.connect({ host: this.host, port: this.port });
const done = (v) => { sock.removeAllListeners(); sock.destroy(); resolve(v); };
sock.setTimeout(1500);
sock.once('connect', () => done(true));
sock.once('timeout', () => done(false));
sock.once('error', () => done(false));
});
}
async start() {
this.validate();
// If something is already responding on the port, assume an external
// ComfyUI is running and don't try to spawn our own.
if (await this.isApiResponsive()) {
if (await this._tryAdopt()) return { external: false, adopted: true };
console.log('[ComfyProcess] External ComfyUI already responding; using it.');
return { external: true };
}
// ★ A ComfyUI we started is alive but not answering yet: it is still
// booting. ComfyUI binds its port only at the very END of its start,
// after every node pack has imported, so the port checks below cannot
// see it. Launching a second one here (a boot that outlasted
// waitForApi, then a retry) left two processes racing for the port: the
// first bound it, the second exited, and the first was then taken for an
// external ComfyUI nobody could stop. Wait for our own instead, and only
// replace one that has not answered for BOOT_PATIENCE_MS. (One that
// already holds the port is merely slow to answer: the port wait below
// handles it, as it always has — not the wait for a PREVIOUS ComfyQ's
// start, which is why that one is skipped for a ComfyUI we own.)
if (this.owned && !await this.isPortBound()) {
const waited = Date.now() - (this._ownedSince || Date.now());
if (waited < BOOT_PATIENCE_MS) {
console.log(`[ComfyProcess] The ComfyUI ComfyQ started on port ${this.port} (PID ${this.pid}) is still starting — waiting for it rather than launching a second one.`);
return { external: false };
}
console.warn(`[ComfyProcess] The ComfyUI on port ${this.port} (PID ${this.pid}) has not answered in ${Math.round(waited / 60000)} min — stopping it and starting a fresh one.`);
const reason = this.nextStartReason;
await this.stop();
this.nextStartReason = reason === 'start' ? 'the previous start never answered' : reason;
} else if (!this.owned && await this._waitForRecordedBoot()) {
if (await this._tryAdopt()) return { external: false, adopted: true };
console.log('[ComfyProcess] External ComfyUI already responding; using it.');
return { external: true };
}
// Nothing answered, but the port may still be held — by a ComfyUI that
// is mid-boot, or by the one the previous ComfyQ owned and is now
// shutting down. This is the situation every restart lands in, and
// spawning a competitor there is what used to take the machine out of
// service: the second process cannot bind, so nothing ever answers.
// Wait it out instead: attach if it comes up, spawn once the port is
// genuinely free.
if (await this.isPortBound()) {
console.log(`[ComfyProcess] Port ${this.port} is held but not answering yet — waiting before spawning.`);
const deadline = Date.now() + PORT_SETTLE_MS;
while (Date.now() < deadline) {
if (await this.isApiResponsive()) {
if (await this._tryAdopt()) return { external: false, adopted: true };
console.log('[ComfyProcess] The ComfyUI already on the port came up; using it.');
return { external: true };
}
if (!await this.isPortBound()) {
console.log('[ComfyProcess] Port is free now — spawning our own ComfyUI.');
break;
}
await new Promise(r => setTimeout(r, 2000));
}
if (await this.isPortBound() && !await this.isApiResponsive()) {
// Still stuck. Spawning would fail to bind, so say so plainly
// rather than leaving a mystery in the log.
throw new Error(`Port ${this.port} is held by a process that never answered the ComfyUI API (waited ${PORT_SETTLE_MS / 1000}s). Close it, then retry.`);
}
}
// ★ Install ComfyQ's own node before EVERY spawn, not only the admin
// calibrator's. It carries two things: the "Open in ComfyUI" opener, and
// the model-access recorder that logs every weight ComfyUI resolves or
// loads. The recorder is the only signal in the prune mechanism that is
// an observation rather than an inference — and it was installed from
// adminCalibrator alone, so a rig in student mode (which is how a rig
// spends its day) recorded nothing at all and the log stayed empty. This
// is the single choke point every spawn goes through, lanes included.
try {
require('../comfyui/openerExtension').ensureInstalled(this.rootPath);
} catch (e) {
// Never block a spawn for this: a rig that cannot serve is worse
// than a rig whose prune tool has one signal fewer.
console.warn(`[ComfyUI] could not install the ComfyQ node (continuing): ${e.message}`);
}
const mainPy = path.join(this.rootPath, 'main.py');
// Portable installs match the run_nvidia_gpu.bat launcher:
// python.exe -s main.py --windows-standalone-build --disable-auto-launch ...
// -s — drops user site-packages from sys.path so a stray pip-installed
// torch (CPU-only / wrong CUDA) can't shadow the embedded torch.
// --windows-standalone-build — ComfyUI's portable-mode path tweaks.
// --disable-auto-launch — suppresses the browser tab that
// --windows-standalone-build otherwise opens (we host our own UI).
// No --highvram: it forces full-load, which thrashes against the
// text encoder when a single model nearly fills VRAM (e.g. LTX-AV
// at 23.8GB on a 24GB card). The default dynamic loader handles
// near-budget cases properly.
const pyArgs = [];
const comfyArgs = [
'--listen', this.bindHost,
'--port', String(this.port),
'--disable-auto-launch'
];
if (this.installationType === 'portable') {
pyArgs.push('-s');
comfyArgs.push('--windows-standalone-build');
}
// SageAttention replaces the attention kernel globally; ComfyUI falls
// back on its own if the package is missing, so a wrong toggle is slow,
// not fatal.
if (this.useSageAttention) comfyArgs.push('--use-sage-attention');
// Pass the single feature by name — bare `--fast` enables everything in
// the PerformanceFeature enum, which ComfyUI itself calls untested.
if (this.fp16Accumulation) comfyArgs.push('--fast', 'fp16_accumulation');
// A second lane's ComfyUI runs from the same install but must not share
// its user or temp directory: the user dir holds a sqlite database that
// ComfyUI locks exclusively (a second process exits immediately), and
// the temp dir is deleted wholesale at startup, which would pull the
// rug from under the lane already running. These paths live under
// ComfyQ — the portable install itself is never written to. ComfyUI
// requires the user dir to exist already, and appends "temp" to
// whatever --temp-directory it is given.
if (this.userDir) {
fs.mkdirSync(this.userDir, { recursive: true });
comfyArgs.push('--user-directory', this.userDir);
// ★ And its own asset database, named explicitly. ComfyUI 0.40 keeps
// the asset DB in the user dir, and when a user dir has none yet it
// "migrates" the install's <root>/user/comfyui.db by RENAMING it to
// .bak — which fails with WinError 32 while the primary lane holds that
// file open, and ComfyUI then exits (ASSETS_STARTUP_FAILED: locked), so
// the lane never serves. An explicit --database-url skips the migration
// altogether (app/database/db.py copy_legacy_default_db).
const dbFile = path.join(this.userDir, 'comfyui.db').split(path.sep).join('/');
comfyArgs.push('--database-url', `sqlite:///${dbFile}`);
}
// ComfyUI-Manager fetches the registry and five lists from github at every
// start; ComfyQ needs none of it, and on a LAN without internet each fetch
// just waits out a timeout. Its only switch is network_mode in the user
// dir's __manager/config.ini, so that is set before the process starts.
if (this.managerOffline) {
const userDir = this.userDir || path.join(this.rootPath, 'user');
try {
const r = require('../comfyui/managerOffline').setManagerOffline(userDir);
if (r !== 'already') console.log(`[ComfyUI] ComfyUI-Manager set to offline (${r}) in ${userDir}`);
} catch (e) {
console.warn(`[ComfyUI] could not set ComfyUI-Manager offline: ${e.message}`);
}
}
if (this.tempDir) {
fs.mkdirSync(this.tempDir, { recursive: true });
comfyArgs.push('--temp-directory', this.tempDir);
}
// Leave room on the card for the other lanes. ComfyUI's DynamicVRAM
// reads actual free memory via NVML, so it already sees the other
// process; this is the politeness margin on top.
if (this.vramHeadroomGb) comfyArgs.push('--vram-headroom', String(this.vramHeadroomGb));
if (this.extraArgs.length) comfyArgs.push(...this.extraArgs);
const args = [...pyArgs, mainPy, ...comfyArgs];
// Sanitize the env so an active conda/venv in the parent shell can't
// leak Python paths into the spawned interpreter. Without this, the
// embedded torch can be shadowed by a CPU-only build → 800+ s/iter.
const env = { ...process.env };
const stripped = [];
const condaPrefix = env.CONDA_PREFIX;
const virtualEnv = env.VIRTUAL_ENV;
for (const k of ['PYTHONPATH', 'PYTHONHOME', 'PYTHONSTARTUP', 'VIRTUAL_ENV', 'CONDA_PREFIX', 'CONDA_DEFAULT_ENV', 'CONDA_PROMPT_MODIFIER', 'CONDA_SHLVL', 'CONDA_PYTHON_EXE']) {
if (env[k] !== undefined) { stripped.push(`${k}=${env[k]}`); delete env[k]; }
}
if (stripped.length) console.log(`[ComfyProcess] Stripped env vars: ${stripped.join(' ')}`);
// Also scrub PATH of any directory under the conda/virtualenv prefix
// we just removed. Conda activation prepends <prefix>, <prefix>\Library\bin,
// <prefix>\Scripts, etc. Those carry a different numpy/CUDA/Pillow that
// Windows can pick up via DLL search order even after we delete CONDA_PREFIX.
const pathKey = env.Path !== undefined ? 'Path' : (env.PATH !== undefined ? 'PATH' : null);
if (pathKey && (condaPrefix || virtualEnv)) {
const prefixes = [condaPrefix, virtualEnv].filter(Boolean).map(p => p.toLowerCase());
const before = env[pathKey].split(path.delimiter);
const after = before.filter(p => !prefixes.some(pre => p.toLowerCase().startsWith(pre)));
if (before.length !== after.length) {
console.log(`[ComfyProcess] Removed ${before.length - after.length} env-prefix entries from PATH`);
env[pathKey] = after.join(path.delimiter);
}
}
// Its output now goes to a FILE, so: UTF-8 (ComfyUI prints ✓ and other
// non-cp1252 characters, which crash a cp1252 stream with
// UnicodeEncodeError) and unbuffered (a file is block-buffered, and the
// milestone scan and the operator's terminal want each line as it comes).
env.PYTHONIOENCODING = 'utf-8';
env.PYTHONUNBUFFERED = '1';
console.log(`[ComfyProcess] Spawning: ${this.pythonExecutable} ${args.join(' ')}`);
const { pid } = await launcher.launchDetached({
exe: this.pythonExecutable, args, cwd: this.rootPath, env,
logBase: launcher.logBaseFor(this.port),
});
this.pid = pid;
this._ownedSince = Date.now();
this._registryMilestoneFired = false;
this._stdoutBuf = '';
launcher.recordStart({ port: this.port, pid, userDir: this.userDir, flags: this._flags(), reason: this.nextStartReason });
this.nextStartReason = 'start';
console.log(`[ComfyProcess] ComfyUI started (PID ${pid}); output in ${launcher.logBaseFor(this.port)}.out.log — it keeps running if ComfyQ restarts.`);
this._follow(false);
this._watchPid();
return { external: false };
}
// Pipes ComfyUI's stdout through with the original `[ComfyUI]` prefix
// AND scans for the comfyregistry fetch terminal log line. On the
// first match per boot, fires onMilestone() so the LAN URL banner
// reprints right after the noisy registry block finishes — that's
// the moment workshop admins actually want the URLs visible.
//
// Matching uses a line buffer because Python's stdout flush can split
// a single line across multiple 'data' chunks; regex-per-chunk would
// miss boundary-crossing matches.
//
// Terminal patterns observed in this codebase / ComfyUI Manager:
// "Comfyregistry has been fetched" (success)
// "Cannot connect to comfyregistry." (offline)
// plus generic done/complete/failed variants for forward compat.
_handleStdoutChunk(chunk) {
// Pass-through write — preserves the existing operator UX.
process.stdout.write(`[ComfyUI] ${chunk}`);
if (this._registryMilestoneFired) return;
this._stdoutBuf += chunk.toString('utf8');
let idx;
while ((idx = this._stdoutBuf.indexOf('\n')) !== -1) {
const line = this._stdoutBuf.slice(0, idx);
this._stdoutBuf = this._stdoutBuf.slice(idx + 1);
// Offline (see managerOffline) there is no registry fetch; Manager
// ends its start with "All startup tasks have been completed".
if ((/comfyregistry/i.test(line) && /(fetched|cannot|failed|done|complete|skip)/i.test(line))
|| /ComfyUI-Manager\] All startup tasks have been completed/i.test(line)) {
this._registryMilestoneFired = true;
this.onMilestone('ComfyUI registry fetch completed');
// Stop buffering — we found what we wanted, no need to
// hold partial lines or scan further.
this._stdoutBuf = '';
return;
}
}
// Guard against pathological input (a single line that never
// terminates) bloating memory. 64 KiB is far past any real log
// line; truncate from the front so we still match the tail.
if (this._stdoutBuf.length > 65536) {
this._stdoutBuf = this._stdoutBuf.slice(-32768);
}
}
async waitForApi(timeoutMs = 120000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (await this.isApiResponsive()) return;
await new Promise(r => setTimeout(r, 1500));
}
throw new Error(`Timeout waiting for ComfyUI API at ${this.host}:${this.port}`);
}
async stop() {
this._stopping = true;
const pid = this.pid;
if (!pid) { this._stopping = false; return; }
if (this._watch) { clearInterval(this._watch); this._watch = null; }
await launcher.killPid(pid);
// Let the OS release the port before anyone spawns on it again.
for (let i = 0; i < 20 && launcher.pidAlive(pid); i++) await new Promise(r => setTimeout(r, 250));
launcher.recordEvent({ port: this.port, pid, event: 'stop', reason: 'stopped by ComfyQ' });
this.pid = null;
this._ownedSince = null;
this._unfollow();
this._stopping = false;
this.emit('exited', { code: null, signal: null, intentional: true });
}
/** Let go of a ComfyUI without stopping it (ComfyQ is shutting down or restarting). */
detach() {
if (this._watch) { clearInterval(this._watch); this._watch = null; }
this._unfollow();
}
}
// Force-kill whatever process is LISTENING on `port`. Used to "take over" an
// externally-started ComfyUI — one ComfyQ didn't spawn, so we hold no child
// handle for it — before relaunching our own on the same port. Best-effort:
// returns { killed: [pids] } (empty if nothing was found), throws only if the
// lookup tool itself can't run. Windows: netstat → taskkill /F /T. POSIX: lsof.
async function killProcessOnPort(port) {
const p = Number(port);
if (!p) throw new Error('killProcessOnPort: a valid port is required');
if (process.platform === 'win32') {
let stdout = '';
try { ({ stdout } = await execFileAsync('netstat', ['-ano'])); }
catch (e) { throw new Error(`netstat failed: ${e.message}`); }
const pids = new Set();
for (const line of stdout.split(/\r?\n/)) {
// " TCP 0.0.0.0:8188 0.0.0.0:0 LISTENING 12345"
const m = line.match(/^\s*TCP\s+\S+:(\d+)\s+\S+\s+LISTENING\s+(\d+)\s*$/i);
if (m && Number(m[1]) === p) pids.add(m[2]);
}
const killed = [];
for (const pid of pids) {
// /T also kills the child processes ComfyUI spawned; access-denied /
// already-gone are non-fatal (best-effort takeover).
try { await execFileAsync('taskkill', ['/F', '/T', '/PID', pid]); killed.push(pid); }
catch { /* already gone / denied */ }
}
return { killed };
}
// POSIX
try {
const { stdout } = await execFileAsync('lsof', ['-ti', `tcp:${p}`, '-sTCP:LISTEN']);
const pids = stdout.split(/\s+/).filter(Boolean);
for (const pid of pids) { try { process.kill(Number(pid), 'SIGKILL'); } catch { /* gone */ } }
return { killed: pids };
} catch {
return { killed: [] };
}
}
module.exports = { ComfyProcess, killProcessOnPort };