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),
381 lines • 19.7 kB
JavaScript
// Connected-panel origins, ORCHESTRATOR → spawned MCP child (#1415).
//
// #952 built the drift comparison (describeTargetDrift, comfyui/fetch.ts): when a
// ComfyUI call fails at the network layer, say whether a CONNECTED panel is on a
// different ComfyUI than the address that failed. The orchestrator installs its
// source from the bridge (orchestrator/index.ts, setConnectedPanelOrigins).
//
// But the orchestrator is not where those calls happen. Every headless comfyui
// tool — `list_packs (action:"list_templates")` among them — runs in the SPAWNED
// stdio child (`node dist/index.js`), which loads orchestrator/index.js never
// (boot.ts imports it only for --panel-orchestrator) and has no bridge. So the
// source was null there, the comparison returned "", and #1415's reporter got the
// generic "a CONNECTED sidebar panel does not imply this address is reachable"
// while the orchestrator was sitting on the exact answer: their panel was on
// :8188 and the call went to the dead COMFYUI_URL.
//
// This is the missing HALF of that channel, and it reuses the plumbing the
// control channel already proved (services/download-progress.ts): a small JSON
// file in COMFYUI_MCP_PROGRESS_DIR, which the orchestrator shares with every
// child it spawns. Named with the same `control-` prefix, so the tray poll and
// the target-change reader both skip it.
//
// LEVEL-TRIGGERED, not event-driven: the orchestrator re-publishes the CURRENT
// set on its existing 700ms poll tick and writes only when it changed. A tab that
// goes away therefore blanks the file within ONE TICK — so the worst case is a
// failure raised inside that window quoting a panel that disconnected up to
// ~700ms ago, not an unbounded stale claim. (An earlier version of this comment
// said the child "can never" quote a disconnected panel. It can, for that
// window; the bound is what makes it acceptable, so the bound is what is
// written down. Review, finding 2.)
//
// ## Staleness across an orchestrator's DEATH (review, finding 1)
//
// The tick only blanks the file while the orchestrator is alive to run it. Kill
// it — crash, task manager, a machine losing power — and the last record stays
// on disk indefinitely, describing panels that are long gone. The progress dir
// outlives the process, so the next orchestrator's children can read it before
// its own first tick republishes.
//
// A plain `updated` comparison cannot fix that, because writes are CHANGE-ONLY:
// a record that is legitimately unchanged for an hour is not stale, and a TTL
// alone would expire it. So the record carries the publisher's `pid` and is
// refreshed on a slow HEARTBEAT independent of change, and the reader requires
// BOTH:
//
// - the publishing process is still alive (`process.kill(pid, 0)`), which
// catches the death case immediately rather than after a timeout; and
// - the record was refreshed recently, which catches the case a bare pid check
// cannot — the OS reusing a dead orchestrator's pid for something unrelated.
//
// Either alone leaves a hole; together the failure mode is "says nothing", which
// is the pre-#952 behaviour and costs a diagnostic sentence rather than
// producing a wrong one.
//
// ### What this still does NOT close, stated rather than implied
//
// Both remaining holes are the SAME shape — a dead publisher whose record is
// younger than PANEL_ORIGINS_MAX_AGE_MS — and both are bounded by it:
//
// - PID REUSE inside the window. The orchestrator crashes, the OS hands its
// pid to something unrelated that is alive, and the record has not yet aged
// out. Both checks pass for the remainder of the window.
// - A ZOMBIE publisher on Linux. A terminated-but-unreaped process still has a
// pid that `kill(pid, 0)` answers for, so liveness says yes while the
// orchestrator is gone. The heartbeat stopped when it died, so this expires
// on the same clock.
//
// Neither is closed by more checking of the same kind — a pid cannot prove it is
// the same process that wrote, and only a lock or a handle the reader can
// interrogate would settle it. That is not worth building here: the payload is a
// DIAGNOSTIC SENTENCE, the worst outcome is naming an origin that stopped being
// connected up to two minutes ago, it self-heals on the next read, and the
// realistic path needs a child that outlived its own parent orchestrator (a live
// one republishes within 30s, and within 700ms of starting). Recorded so the
// next reader does not have to re-derive it — and so the bound is a number
// somebody chose, not one that fell out.
//
// No-ops entirely when COMFYUI_MCP_PROGRESS_DIR is unset — a plain (non-panel)
// MCP server keeps the pre-#952 "" exactly as before.
import { closeSync, constants, fstatSync, mkdirSync, openSync, readSync, renameSync, rmSync, writeFileSync, } from "node:fs";
import { join } from "node:path";
/**
* The open flags, resolved at CALL time and defensively.
*
* Destructuring `constants` at module scope broke ten unrelated suites: this
* module is pulled in transitively by comfyui/fetch.ts, and a partial
* `vi.mock("node:fs")` that does not re-export `constants` makes the destructure
* throw during IMPORT — every test in the file then collects zero tests, with an
* error that names this line rather than the mock. Reading it inside the
* function means a mocked fs is simply a mocked fs.
*
* Falls back to the "r" string flag when the numbers are unavailable, so the
* open still happens; O_NONBLOCK is undefined on Windows, where the FIFO case it
* guards does not arise.
*/
function readFlags() {
const rd = constants?.O_RDONLY;
if (typeof rd !== "number")
return "r";
const nonblock = constants?.O_NONBLOCK;
return rd | (typeof nonblock === "number" ? nonblock : 0);
}
/** File name inside the progress dir. The `control-` prefix is load-bearing:
* pollDownloads and listTargetChangeRequests both filter on it, so this file is
* never mistaken for a download row or a target-change request. Kept as a
* literal (not an import of CONTROL_PREFIX) so this module stays a leaf that
* comfyui/fetch.ts can depend on; a test pins the two together. */
export const PANEL_ORIGINS_FILE = "control-panel-origins.json";
/** Read at CALL time, not at module load: a test can point the channel at a temp
* dir, and nothing in production changes it after spawn anyway. */
function channelFile(dir) {
const base = dir ?? process.env.COMFYUI_MCP_PROGRESS_DIR ?? "";
return base ? join(base, PANEL_ORIGINS_FILE) : null;
}
/** Last payload written by THIS process, so the 700ms tick writes only on a
* change rather than once per tick forever. */
let lastPublished = null;
/** When this process last wrote the file, for the heartbeat below. */
let lastWriteAt = 0;
/** Distinguishes concurrent temp files from this process. */
let publishSeq = 0;
/**
* A temp path no other writer can be using.
*
* pid + sequence alone is not unique: Node worker threads share `process.pid`
* and each initialises its own module state, so two workers both start at
* sequence 0 and pick the same name — one then renames the other's payload
* (review r3). Only one publisher exists today, which is exactly why this is
* worth making unconditional rather than relying on that staying true.
*
* NOT covered by a test, and mutation testing says so: stripping the entropy
* kills nothing, because a single-publisher suite cannot observe a collision.
* Kept as cheap insurance against a condition the tests cannot create, and
* recorded here so the next reader does not mistake the survivor for dead code.
*/
function tempPathFor(file) {
const entropy = Math.random().toString(36).slice(2, 10);
return `${file}.${process.pid}-${publishSeq++}-${entropy}.tmp`;
}
/**
* Write `payload` to `file` atomically; returns whether it LANDED.
*
* Write-then-rename, NOT an in-place rewrite. `writeFileSync(file, …)`
* truncates and refills, so a child reading during that window sees a
* partial record, fails to parse, and answers [] — dropping every live
* origin at exactly the moment it is formatting the error those origins
* explain (review r2). A rename makes readers see the old record or the new
* one, never a torn one.
*
* Same shape as persistDownloadJob (download-progress.ts), including its
* reason for retrying: on Windows a reader briefly holding the target open
* makes rename fail transiently. On persistent failure the PRIOR complete
* record is left untouched and the temp dropped — the next tick retries, so
* this self-heals rather than degrading to a torn write.
*
* Exported for the sibling control channel (frontend-virtual-types.ts, #1400):
* one atomic-write implementation, so the two cannot drift apart.
*/
export function writeChannelPayload(dir, file, payload) {
try {
mkdirSync(dir, { recursive: true });
const tmp = tempPathFor(file);
let renamed = false;
try {
writeFileSync(tmp, payload);
for (let attempt = 0; attempt < 5 && !renamed; attempt++) {
try {
renameSync(tmp, file);
renamed = true;
}
catch {
/* transient (e.g. Windows sharing violation) — retry */
}
}
}
finally {
// In a `finally`, so a THROWING write is cleaned up too. With the removal
// sitting after the loop instead, a `writeFileSync` that failed partway —
// a full disk is the ordinary cause — left its partial temp behind and
// added another on every tick thereafter (review r3).
if (!renamed) {
try {
rmSync(tmp, { force: true });
}
catch {
/* ignore — a stray .tmp is not the channel file and is never read */
}
}
}
return renamed;
}
catch {
return false; // retried on the next tick
}
}
/** Refresh the record at least this often even when the set is UNCHANGED, so a
* reader can tell "still true" from "nobody has touched this since the
* orchestrator died". 30s against a 700ms tick is ~2 writes a minute — the
* change-only rule is what this exists to preserve, so it stays far away from
* the per-tick write it replaced. */
export const PANEL_ORIGINS_HEARTBEAT_MS = 30_000;
/** How stale a record may be before the reader stops trusting it. Generous
* against the heartbeat (4x) so ordinary scheduling jitter, a busy event loop,
* or a slow disk never expires a live orchestrator's record — the cost of
* expiring a good one is a lost diagnostic, but flapping would be worse. */
export const PANEL_ORIGINS_MAX_AGE_MS = 120_000;
/** A malformed or hostile-sized file must not delay the network error it is
* being read to DESCRIBE (review, finding 3). The real payload is a handful of
* origins; anything past this is not the file we wrote. */
const MAX_CHANNEL_BYTES = 64 * 1024;
/**
* Read the channel file with a hard byte bound, through a single descriptor.
*
* Throws on anything unreadable, which the caller's catch turns into [] — the
* "say nothing" answer.
*
* O_NONBLOCK is part of the OPEN, not a detail of the read. A FIFO at this path
* blocks `openSync` itself until a writer arrives, so an fstat-based "is this a
* regular file?" check placed after the open can never run — the process is
* already parked, inside the error path, forever. (Written the other way first,
* with a comment claiming the fstat covered it. It did not.) Undefined on
* Windows, where the case does not arise, so it falls back to 0.
*
* Exported for the sibling control channel (frontend-virtual-types.ts, #1400):
* one bounded-read implementation, so the two cannot drift apart.
*/
export function readChannelFile(file) {
const fd = openSync(file, readFlags());
try {
const stat = fstatSync(fd);
// NOT covered by a portable test, and deliberately kept anyway. On Windows
// `openSync` already rejects a directory, and mkfifo does not exist there, so
// nothing this suite can build reaches this line — mutation testing correctly
// reports deleting it as killing no test. It states the POSIX case (a FIFO
// survives the non-blocking open and is not something to read), which is the
// reason the guard exists rather than an accident of the file layout.
if (!stat.isFile())
throw new Error("panel-origins channel is not a regular file");
if (stat.size > MAX_CHANNEL_BYTES)
throw new Error("panel-origins channel is oversized");
// Sized from the fstat above, NOT re-clamped. A `Math.min(size, MAX)` here
// was removed: it is unreachable behind the check on the previous line, and
// worse, it MASKED it — an oversized file was truncated into a parse error,
// so the suite passed with either guard deleted and neither was proven.
const buf = Buffer.allocUnsafe(stat.size);
const read = readSync(fd, buf, 0, buf.length, 0);
return buf.subarray(0, read).toString("utf-8");
}
finally {
closeSync(fd);
}
}
/** Is the process that published this record still running?
*
* `process.kill(pid, 0)` sends no signal — it only asks. EPERM means the pid
* exists but belongs to another user, which still answers "alive". Anything
* unreadable answers false, because this gates a claim and an unanswerable
* question must not grant it. Exported for the sibling control channel
* (frontend-virtual-types.ts, #1400). */
export function publisherAlive(pid) {
if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0)
return false;
try {
process.kill(pid, 0);
return true;
}
catch (err) {
return err?.code === "EPERM";
}
}
/**
* Orchestrator side: publish the origins the connected tabs actually front.
*
* `dir` is EXPLICIT because the orchestrator's own COMFYUI_MCP_PROGRESS_DIR is
* unset — it computes progressDir itself and only the children inherit it (the
* same reason listTargetChangeRequests takes a dir).
*
* Best-effort: a failed write leaves the child on the previous value or on "",
* which costs a diagnostic sentence and never a wrong one.
*/
export function publishConnectedPanelOrigins(dir, origins, now = Date.now()) {
const file = channelFile(dir);
if (!file)
return;
// What actually gets published: filtered, deduped, ordered. The comparison and
// the payload are both built from THIS, so they cannot disagree.
const published = [...new Set(origins.filter((o) => typeof o === "string" && o !== ""))].sort();
// Compare WITHOUT the timestamp — otherwise every tick differs and this writes
// 86k files an hour to say nothing changed.
//
// …and compare the SET, not the caller's array. The key used to be
// `JSON.stringify(origins)` while the payload was the filtered list, so two
// ticks reporting the same connected panels in a different order — or with a
// duplicate, or with an empty string appearing and vanishing — rewrote the
// file to say exactly the same thing (review r2). `connectedServerOrigins()`
// walks a live socket collection, so ordering is not something to assume.
const key = JSON.stringify(published);
// …but DO write when the heartbeat is due, even unchanged: an unrefreshed
// record is exactly what a dead orchestrator leaves behind, so "unchanged" and
// "nobody is home" have to be distinguishable on disk.
const changed = key !== lastPublished;
const heartbeatDue = now - lastWriteAt >= PANEL_ORIGINS_HEARTBEAT_MS;
if (!changed && !heartbeatDue)
return;
const payload = JSON.stringify({
origins: published,
updated: now,
pid: process.pid,
// This channel is diagnostic-only. Its records must never become an
// authorization source for a new outbound request.
diagnosticPanelOrigins: true,
});
// The atomic write lives in writeChannelPayload (shared with the sibling
// frontend-virtual-types channel, #1400); a false return means the PRIOR
// complete record was left in place, so do NOT record this as published —
// the next tick retries.
if (!writeChannelPayload(dir, file, payload))
return;
lastPublished = key;
lastWriteAt = now;
}
/** Test/shutdown hook: forget what this process last published, so the next
* publish writes unconditionally. */
export function resetPublishedPanelOrigins() {
lastPublished = null;
lastWriteAt = 0;
}
/**
* Child side: the origins the orchestrator last published, or `[]` when there is
* no channel (a plain MCP server), nothing has been published yet, or the file is
* mid-write. `[]` means UNKNOWN and the caller must say nothing about drift —
* never "there is no drift".
*/
export function readPublishedPanelOrigins(now = Date.now()) {
const file = channelFile();
if (!file)
return [];
try {
// Bounded read through ONE descriptor. `statSync` then `readFileSync` names
// the path twice, so the thing measured need not be the thing read — the
// file can grow or be replaced in between, and the bound applies to neither
// (review r2). Opening once and asking that fd for its size closes the gap,
// and reading into a fixed buffer bounds the work even if the answer is a
// lie. This runs while formatting a network failure; it must not delay the
// error it exists to explain.
const raw = JSON.parse(readChannelFile(file));
if (!Array.isArray(raw?.origins))
return [];
// Old records carried every server-observed origin, including relay/tunnel
// targets. They are diagnostic data, not permission to make a new request.
if (raw.diagnosticPanelOrigins !== true)
return [];
// A record whose publisher is gone describes panels that are gone with it.
// Both checks, for the reasons in the header: liveness catches the death,
// freshness catches a reused pid.
if (!publisherAlive(raw.pid))
return [];
// A missing or non-numeric stamp becomes 0, which the age comparison below
// rejects on its own — `now - 0` is ~55 years. An explicit `updated <= 0`
// guard was written here and removed: mutation testing showed deleting it
// killed nothing, because it is unreachable behind that arithmetic for any
// clock later than 1970. Redundant code that looks load-bearing is its own
// hazard.
const updated = typeof raw.updated === "number" ? raw.updated : 0;
// `updated > now` (a clock step, or a future-dated write) is NOT freshness
// evidence — without this it would read as maximally fresh, forever.
if (updated > now || now - updated > PANEL_ORIGINS_MAX_AGE_MS)
return [];
// Preserve malformed strings for panel-fallback-target.ts to reject as a
// mixed set. Filtering one invalid entry here would silently select the
// remaining valid origin, which is exactly the fail-open bug this channel
// boundary is meant to prevent.
if (!raw.origins.every((o) => typeof o === "string" && o !== ""))
return [];
return raw.origins;
}
catch {
return [];
}
}
//# sourceMappingURL=panel-origin-channel.js.map