comfyui-mcp
Version:
Local-first, agent-native control plane for ComfyUI — MCP server + autonomous sidebar agent that drives your live graph in natural language on ANY LLM: Claude/ChatGPT/Gemini on your subscription (no API key), free local models via Ollama (fully offline),
435 lines • 22.3 kB
JavaScript
// Who owns the healthy listener after a launch? (#776)
//
// This module exists to make one invariant UNFORGEABLE rather than merely
// documented: a definite verdict — `ours` or `not-ours` — may only be produced by
// the code that actually gathered the evidence for it. The same defect kept
// reappearing on new paths (#796): an early return naming a definite answer it had
// no basis for. A comment could not stop that; a type can.
//
// `ListenerOwnership` is a branded string. The values serialise and compare as
// ordinary `"ours"` / `"not-ours"` / `"unconfirmed"`, but the type carries a
// phantom property no literal satisfies, and the only cast that mints one lives in
// `classified()` — which is private to this file and used solely inside
// `classifyListenerOwnership`. Callers get exactly two ways to obtain a value:
// classify (and earn a verdict), or `unclassifiedOwnership()` (and admit you did
// not). Living in its own module is the point: in a 2600-line file the cast was
// reachable by any future early return, which made the guarantee "that file's
// discipline" rather than the compiler's.
import { commandLineMatchesArgv } from "./live-interpreter.js";
/** Mint a verdict. PRIVATE — the whole point of the module boundary. */
function classified(verdict) {
return verdict;
}
/** The only verdict a caller that did NOT classify is entitled to return. */
export function unclassifiedOwnership() {
return "unconfirmed";
}
function classifiedSupervision(verdict) {
return verdict;
}
/** The only verdict a caller that did NOT classify is entitled to return. */
export function unclassifiedSupervision() {
return "unconfirmed";
}
export function compareStartTimes(parent, child) {
if (!parent || !child)
return "unknown";
const p = parent.trim();
const c = child.trim();
if (/^\d+$/.test(p) && /^\d+$/.test(c)) {
return BigInt(p) <= BigInt(c) ? "parent-first" : "parent-newer";
}
const pd = Date.parse(p);
const cd = Date.parse(c);
if (Number.isNaN(pd) || Number.isNaN(cd))
return "unknown";
return pd <= cd ? "parent-first" : "parent-newer";
}
/**
* Is a live Desktop supervisor still watching `pid`?
*
* ANCESTRY, WALKED UPWARD — which is the opposite direction from
* `isDescendantOfChild` above, and for a different question. That one asks "is this
* the child WE spawned?", where tracing to a long-lived ancestor proves nothing
* because every stale sibling shares it. This one asks "is anything above this
* process going to restart it?", and an ancestor is precisely what can: the Desktop
* shell spawns the Python backend, so the shell IS the supervisor. Intermediate
* wrappers (a launcher script, a shim) are walked THROUGH rather than treated as an
* answer.
*
* `supervised` — an ancestor is alive and its command line is a Desktop shell.
* Positive: there is something there to re-exec the process.
* `abandoned` — the chain was READ to its top and no live Desktop shell stands on
* it: either the parent's number is provably vacant (the shell
* exited) or the tree root was reached with every step readable and
* none of them a supervisor. Positive too — this is the #814 shape,
* and it is what refuses a stop.
* `unconfirmed`— the chain became unreadable, a pid could not be probed, or the hop
* budget ran out. NOT an answer in either direction — and note that
* it is the CALLER that decides what to do with it. For a reboot,
* which is irreversible and has not happened yet, the caller refuses —
* with one disclosed exception (#1647: the FIRST link unreadable on a
* host that cannot read parentage at all, where the server's own
* Desktop launch signatures say who started it): see
* assessDesktopSupervision. Each such outcome carries `because`, so a
* refusal can name what it failed to establish, and
* `parentUnreadableAt`, so the fallback can tell the first-hop shape
* from a chain that ran out of road partway up.
*
* The hop budget is small on purpose. A Desktop backend sits one or two levels under
* its shell; a chain longer than that is not a layout we can reason about, and
* "cannot tell" is the honest verdict for it.
*/
export function classifyDesktopSupervision(input, maxHops = 8) {
const unconfirmed = (because) => ({
verdict: classifiedSupervision("unconfirmed"),
because,
});
const definite = (v) => ({
verdict: classifiedSupervision(v),
});
// NO SELF-SUPERVISOR SHORTCUT. An earlier revision returned `supervised` when the
// pid handed in was ITSELF a Desktop shell, to serve the caller's fallback of
// "ComfyUI could not be attributed to the port, so use whatever Desktop shell is
// running". But that fallback picks a shell by scanning process NAMES — it is bound
// to no port and to no backend, so "this pid is a Desktop shell" says nothing about
// whether it supervises the server the reboot would stop. A second, unrelated
// Desktop window would have licensed stopping an orphaned backend it has never
// heard of, which is the same lost server by a new route (codex gate round 9).
//
// The premise, not the shortcut, was the problem: that caller now declines before
// reaching here, so every pid this classifier sees is one resolved FROM THE PORT —
// a backend, whose supervisor is genuinely somewhere above it.
const self = input.readIdentity(input.pid);
let current = input.pid;
let currentStartedAt = self?.startedAt;
for (let hop = 0; hop < maxHops; hop++) {
const parent = input.readParentPid(current);
// The chain stopped being readable. Nothing was learned in either direction.
// WHICH pid failed is recorded: a caller that falls back to weaker evidence
// (#1647) may do so only when the FIRST link — the port owner's own parent —
// is the unreadable one, not when a walk that got partway up ran out of road.
if (parent == null) {
return {
...unconfirmed(`the parent process of PID ${current} could not be read`),
parentUnreadableAt: current,
};
}
// A process cannot be its own parent; that reading is damage, not a tree root.
if (parent === current)
return unconfirmed(`PID ${current} was reported as its own parent, which is not a tree this can be read from`);
// The TOP of the tree, reached without passing a supervisor. On POSIX an
// orphan is reparented to init (1) — that reparenting is itself the record
// that whoever spawned it has gone.
if (parent <= 1)
return definite("abandoned");
const alive = input.processExists(parent);
// THE #814 SIGNAL: the parent's number is vacant, so the shell that spawned this
// server has exited. Nothing is left to act on a reboot request.
if (alive === false)
return definite("abandoned");
// Exists, but we could not establish that — do not spend it either way.
if (alive !== true)
return unconfirmed(`it could not be established whether PID ${parent} (the parent of PID ${current}) is still running`);
const identity = input.readIdentity(parent);
// Something holds that number and we cannot see WHAT it is. A pid can be
// RECYCLED, so "a process exists there" is not evidence a supervisor does.
//
// "What it is" now has two possible sources, and either will do: the command
// line, or the OS's own record of the binary. Gating on the command line alone
// was a leftover from when it was the only one — a process whose command line is
// unreadable but whose EXECUTABLE the OS names would stop the walk here, and the
// authenticated evidence that exists precisely to settle this question would
// never be consulted (codex gate round 4).
if (!identity || (!identity.commandLine && !identity.executablePath)) {
return {
...unconfirmed(`PID ${parent} (the parent of PID ${current}) exists but what it is running could not be read`),
parentIdentityUnreadableAt: current,
};
}
// IS THIS REALLY THE PARENT, or just whoever holds that number now? A pid
// recorded in a child's record outlives the process that earned it, and the
// replacement can look exactly like a supervisor.
const order = compareStartTimes(identity.startedAt, currentStartedAt);
// The process at that number started AFTER its supposed child, so the real
// parent has exited — which is what made the number reusable. That is positive
// evidence the supervisor is gone.
if (order === "parent-newer")
return definite("abandoned");
// No usable stamps on one side or the other: the link is unverified, so nothing
// built on it may be claimed.
if (order === "unknown") {
return unconfirmed(`PID ${parent} could not be confirmed as the parent of PID ${current} — the process ` +
`start times needed to rule out a reused PID could not be compared`);
}
if (input.isSupervisorProcess(identity))
return definite("supervised");
current = parent;
currentStartedAt = identity.startedAt;
}
// Ran out of hops: did not finish looking.
return unconfirmed(`no Desktop supervisor was found within ${maxHops} levels above PID ${input.pid}, and the ` +
`walk ran out before reaching the top of the process tree`);
}
function dialectOf(token) {
if (/^[a-zA-Z]:[\\/]/.test(token) || /^\\\\/.test(token))
return "win-rooted";
if (/^[a-zA-Z]:[^\\/]/.test(token))
return "drive-relative";
if (token.includes("\\") && !token.includes("/"))
return "win-relative";
return "posix";
}
function prepareToken(raw) {
const trimmed = raw.trim().replace(/^["']+|["']+$/g, "");
const dialect = dialectOf(trimmed);
if (dialect === "drive-relative") {
return { text: trimmed, foldable: false, unresolvable: true };
}
// Separators are only interchangeable in a Windows dialect. On POSIX a backslash
// is part of the name and must stay.
const windows = dialect === "win-rooted" || dialect === "win-relative";
let t = windows ? trimmed.replace(/\\/g, "/") : trimmed;
// Extended-length and UNC prefixes denote the same locations as their plain
// spellings.
t = t.replace(/^\/\/\?\/unc\//i, "//").replace(/^\/\/\?\//, "");
if (t.includes("/")) {
const drive = /^([a-zA-Z]:)(?=\/)/.exec(t)?.[1] ?? "";
const rooted = t.startsWith("/") || drive !== "";
const body = t.slice(drive.length);
const out = [];
for (const seg of body.split("/")) {
if (seg === "" || seg === ".")
continue;
if (seg === "..") {
if (out.length > 0 && out[out.length - 1] !== "..")
out.pop();
// At a ROOT, `..` has nowhere to go — the OS clamps it, so we must too,
// or `/../ComfyUI` and `/ComfyUI` (the same directory) read as different.
else if (!rooted)
out.push("..");
continue;
}
out.push(seg);
}
t = `${drive}${rooted ? "/" : ""}${out.join("/")}`;
}
return { text: t, foldable: dialect === "win-rooted", unresolvable: false };
}
/**
* Do two argv tokens denote the same thing?
*
* Case is folded ONLY when a side is rooted in a Windows path (that filesystem is
* case-insensitive); everywhere else the comparison is exact, because a spurious
* equality here can promote a listener to `ours`. Paths compare SEGMENT-ALIGNED —
* one may be a suffix of the other — since we launch the script by absolute path
* while the server reports the relative one it was handed.
*/
function tokensAgree(a, b) {
if (a.unresolvable || b.unresolvable)
return false;
const fold = a.foldable || b.foldable;
const x = fold ? a.text.toLowerCase() : a.text;
const y = fold ? b.text.toLowerCase() : b.text;
if (x === y)
return true;
const pathish = (s) => /\//.test(s) || /^[a-zA-Z]:/.test(s);
if (!pathish(x) || !pathish(y))
return false;
return x.endsWith(`/${y}`) || y.endsWith(`/${x}`);
}
// ---------------------------------------------------------------------------
// Liveness and lineage
// ---------------------------------------------------------------------------
/**
* Is the process we spawned still alive? Tri-state, because only a DEFINITE answer
* may change a verdict.
*
* false — an exit was recorded, or a signal-0 probe says ESRCH: it is gone.
* undefined — no pid to probe, or EPERM (something holds that number but it is
* not ours to signal). "Cannot tell", never "alive" and never "dead".
* true — the pid exists and is signalable. Necessary, NOT sufficient: a
* recycled number passes too, which is why lineage is also required.
*/
export function launchedChildStillRunning(child, kill = (pid, signal) => {
process.kill(pid, signal);
}) {
const pid = child.pid;
if (pid == null)
return undefined;
// Loose null check: a not-yet-exited child reports `null`, and an absent
// property must read the same way rather than as "already exited".
if (child.exitCode != null || child.signalCode != null)
return false;
try {
kill(pid, 0);
return true;
}
catch (err) {
return err.code === "ESRCH" ? false : undefined;
}
}
/**
* Is `pid` THIS CALL'S CHILD, or a descendant of it?
*
* Traced to `childPid`, deliberately NOT to the orchestrator. Ancestry under the
* long-lived MCP process proves almost nothing: a stale ComfyUI from an EARLIER
* request is also descended from it, has identical argv, and may still hold the
* port — so tracing to the orchestrator would report that sibling as `ours` and
* announce a restart that never happened, while the child we just spawned may have
* died on a bind failure. Only lineage through this launch's own child answers the
* question actually being asked.
*
* `undefined` the moment the chain becomes unreadable, and on budget exhaustion:
* running out of hops is "did not finish looking", not a negative answer.
*/
export function isDescendantOfChild(pid, childPid, readParentPid, maxHops = 8) {
let current = pid;
for (let hop = 0; hop < maxHops; hop++) {
if (current === childPid)
return true;
const parent = readParentPid(current);
if (parent == null)
return undefined;
// Reached the top of the tree without passing through our child.
if (parent === current || parent <= 1)
return false;
current = parent;
}
return undefined;
}
export function classifyListenerOwnership(input) {
/**
* Does the server that is answering run what we launched?
*
* ASYMMETRIC by design. A MISMATCH is proof of the negative — nobody else's
* command line is ours. A match is only CORROBORATION: another supervisor can
* start the very same command, win the bind race, and answer identically, so it
* may never promote anything to `ours`. And `unknown` is neither: it is the
* absence of a comparison, and must not be spent as either.
*/
const byArgv = () => {
if (!input.servingArgv?.length || !input.launchArgv?.length)
return "unknown";
// The interpreter is dropped from our side: the server reports `sys.argv`,
// which never contains it. Order is preserved on both sides (a relaunch passes
// the same arguments in the same order), so compare positionally, and require
// the same COUNT so a foreign subset cannot pass as a match.
const ours = input.launchArgv.slice(1).map(prepareToken).filter((t) => t.text);
const serving = input.servingArgv.map(prepareToken).filter((t) => t.text);
if (ours.length === 0 || serving.length === 0)
return "unknown";
// A drive-relative token resolves against a per-drive working directory we do
// not know, so identical text can denote different installs. Not comparable.
if (ours.some((t) => t.unresolvable) || serving.some((t) => t.unresolvable)) {
return "unknown";
}
const same = ours.length === serving.length &&
ours.every((token, i) => tokensAgree(token, serving[i]));
return same ? "match" : "differ";
};
// A LAUNCH THAT NEVER HAPPENED is decisive on every path, Desktop included, so
// these come first and no later shortcut can soften them. The spawn error is
// latched independently of the readiness race, and Node leaves `pid` undefined
// only when the spawn did not happen — the same fact by two routes.
if (input.spawnFailed)
return classified("not-ours");
if (input.child.pid == null)
return classified("not-ours");
// A Desktop launch is undecidable BY DESIGN once it HAS started: we spawn the
// Electron shell (or macOS `open`, which exits immediately by design) and its
// child binds the port. Sits after the never-launched checks, before
// `childExited`, because for Desktop a launcher exiting is normal.
if (input.isDesktopApp)
return classified("unconfirmed");
const alive = input.childIsAlive;
// OUR DIRECT CHILD IS GONE — by its `exit` event or by a signal-0 probe. Usually
// that means the listener is somebody else's, but it is ALSO the shape of a
// wrapper that launched ComfyUI as a grandchild and exited, after which the
// grandchild is reparented and lineage cannot place it. So only a real, comparable
// MISMATCH is decisive here; a match and an unreadable comparison alike degrade,
// rather than telling that user their own server is not theirs.
if (input.childExited || alive === false) {
return byArgv() === "differ"
? classified("not-ours")
: classified("unconfirmed");
}
const ourPid = input.child.pid;
if (input.portOwnerPid == null) {
// NO LISTENER WAS IDENTIFIED. `not-ours` is a positive finding about an
// identified process, so it cannot be reached from here — there is nothing to
// have found.
return classified("unconfirmed");
}
if (input.portOwnerPid !== ourPid) {
// A different pid holds the port. That is usually somebody else's server, but
// it is also the shape of an indirect launch (wrapper, double fork,
// trampoline) where the listener is our GRANDchild — so trace lineage through
// THIS launch's child before concluding.
const descendant = isDescendantOfChild(input.portOwnerPid, ourPid, input.readParentPid);
// Lineage does not outrank direct contrary evidence: a wrapper of ours can
// bring up a DIFFERENT or stale ComfyUI, which would be in our tree while
// plainly not being what we launched.
if (descendant === true) {
return byArgv() === "differ" ? classified("not-ours") : classified("ours");
}
if (descendant === false)
return classified("not-ours");
return byArgv() === "differ"
? classified("not-ours")
: classified("unconfirmed");
}
// The pid MATCHES — but it could be a recycled number we cannot signal.
if (alive !== true)
return classified("unconfirmed");
// LINEAGE is the discriminator that does not depend on WHEN we looked: we
// spawned this child, so the process on the port must still be it. A recycled
// number has a different parent. Not knowing is never promoted to `ours`.
const parent = input.readParentPid(ourPid);
if (parent == null)
return classified("unconfirmed");
if (parent !== process.pid)
return classified("not-ours");
// Belt and braces: the OS's view of the command line, and the server's own
// account of itself, must both agree. Each is a decisive negative only.
const identity = input.readIdentity(ourPid);
if (identity?.commandLine &&
input.launchArgv &&
!commandLineMatchesArgv(identity.commandLine, input.launchArgv)) {
return classified("not-ours");
}
if (byArgv() === "differ")
return classified("not-ours");
return classified("ours");
}
/**
* Is the process holding the panel bridge ours to stop?
*
* `not-ours` — it does not speak the panel protocol. NEVER prompt, NEVER
* `taskkill`, NEVER sweep the port. Same shape as #814: do not
* stop a process we cannot prove is ours and cannot relaunch.
* `ours` — it answered the protocol. Today's reclaim flow (prompt, then
* tree-kill) applies; the lockfile being missing/stale is the
* orphaned-session case that reclaim exists for.
*
* Supervision is minted here for the same reason ownership is: a definite
* verdict has to come from this function. A protocol speaker is an abandoned
* orchestrator (nothing but the user will bring it back). A foreign holder is
* `unconfirmed` — we did not walk its ancestry, and we do not need to, because
* `not-ours` already refuses the stop.
*/
export function classifyBridgeHolder(evidence) {
if (!evidence.speaksPanelProtocol) {
return {
ownership: classified("not-ours"),
supervision: classifiedSupervision("unconfirmed"),
reclaimable: false,
};
}
return {
ownership: classified("ours"),
supervision: classifiedSupervision("abandoned"),
reclaimable: true,
};
}
//# sourceMappingURL=listener-ownership.js.map