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),
177 lines • 7.89 kB
JavaScript
import { performance } from "node:perf_hooks";
import { logger } from "../utils/logger.js";
/**
* Freshness window for a detected dialect. 60s keeps the probe off the hot path
* for any realistic burst of Manager operations while bounding how long an
* out-of-band Manager upgrade + restart can be mis-routed. Override with
* COMFYUI_MCP_MANAGER_API_TTL_MS (0 re-probes on every call; a large value
* restores the old lifetime-cache behavior).
*/
const MANAGER_API_TTL_MS = (() => {
const raw = process.env.COMFYUI_MCP_MANAGER_API_TTL_MS;
if (raw === undefined || raw.trim() === "")
return 60_000;
const n = Number(raw);
return Number.isFinite(n) && n >= 0 ? n : 60_000;
})();
/**
* Elapsed-time source for the freshness window. MONOTONIC on purpose: a wall
* clock can be stepped in either direction (NTP correction, VM snapshot restore,
* a manual fix), and Date.now() arithmetic then misreports how much real time has
* passed — a backward step can make an already-expired entry read as fresh again
* once wall time catches back up to the stamp. performance.now() only ever moves
* forward, so the window always measures actual elapsed time.
*/
function monotonicNow() {
return performance.now();
}
let cache = null;
/**
* Invalidation epoch. resetManagerApiCache() bumps it; a detection that STARTED
* under an older epoch must not commit its result, otherwise a probe already in
* flight when ComfyUI restarted would re-pin the PRE-restart dialect right after
* the restart cleared it (the same guard /object_info carries — #528).
*/
let epoch = 0;
/**
* Ordering ticket for concurrent detections. A reset is not the only way a
* detection can be overtaken: once the TTL lapses, two operations can probe
* CONCURRENTLY, and if a restart lands between them the two probes read DIFFERENT
* servers. Completion order says nothing about which reading is newer, so writes
* are ordered by when the detection STARTED: each takes a ticket up front, and a
* commit is accepted only when no LATER-STARTED detection has already committed.
* The newest reading therefore always ends up pinned, whichever probe finishes
* first, and the loser simply returns its own value to its own caller.
*/
let seqCounter = 0;
let lastCommittedSeq = 0;
/** Current invalidation epoch — capture it before an async operation and pass it
* back to the commit helpers so a concurrent RESET wins. */
export function managerApiEpoch() {
return epoch;
}
export function managerApiCacheStamp() {
return { epoch, seq: ++seqCounter };
}
// ---------------------------------------------------------------------------
// Negative-result cooldown for the per-call dialect re-check
// ---------------------------------------------------------------------------
/**
* When a Manager call fails with a status that COULD mean a stale dialect, the
* caller re-probes to find out. If the probe says the dialect is unchanged, the
* failure had nothing to do with the dialect — and an endpoint that keeps failing
* that way (a persistently-400ing route, a pack Manager always refuses) would
* otherwise buy a fresh probe on EVERY call, since each re-check resets the entry
* the previous one just repopulated.
*
* So a confirmed-unchanged (or unreachable) verdict arms a cooldown for that base
* URL: re-checks are suppressed until it lapses, capping the cost of a repeatedly
* failing operation at one probe per window instead of one per call. Any genuine
* new information — a restart, a stop, any resetManagerApiCache() — clears it, so
* the cooldown can never delay reacting to a real dialect change.
*/
let recheckCooldown = null;
/** Is a dialect re-check for `base` still inside its cooldown window? */
export function dialectRecheckSuppressed(base) {
if (!recheckCooldown || recheckCooldown.base !== base)
return false;
if (monotonicNow() >= recheckCooldown.until) {
recheckCooldown = null;
return false;
}
return true;
}
/** Arm the cooldown after a re-check that produced no new dialect. */
export function suppressDialectRecheck(base, reason) {
recheckCooldown = { base, until: monotonicNow() + MANAGER_API_TTL_MS };
logger.debug("Suppressing Manager dialect re-checks for this base", {
base,
reason,
for_ms: MANAGER_API_TTL_MS,
});
}
/**
* The cached dialect for `base`, or undefined when there is none, it was
* detected against a different base URL, or it has aged out of the freshness
* window.
*
* Age is measured on the MONOTONIC clock (see monotonicNow), so stepping the
* system clock — in either direction — can neither extend the window nor
* resurrect an entry that has already aged out. The `age >= 0` check is kept as a
* defensive floor: a monotonic source cannot produce a negative age, and if one
* ever appeared the safe reading is EXPIRED, not fresh (#528 review).
*/
export function getCachedManagerApi(base) {
if (!cache || cache.base !== base)
return undefined;
const age = monotonicNow() - cache.at;
if (age >= 0 && age < MANAGER_API_TTL_MS)
return cache.api;
logger.debug("Manager API dialect cache expired — re-probing", {
base,
was: cache.api,
age_ms: age,
});
return undefined;
}
/**
* Record a detected dialect, subject to an optional `guard` captured before the
* work that produced it:
* • `epoch` mismatch → something INVALIDATED the cache while this was in
* flight (a restart), so the conclusion may describe a server that is gone.
* • `seq` older than the last accepted commit (detections only) → a
* LATER-STARTED detection has already committed, so this is the older
* reading and must not overwrite it.
* A guard WITHOUT a `seq` is a barrier write: it is not a probe reading but a
* conclusion proven by an operation that actually succeeded (the #464 v2-batch
* demotion), so it supersedes probe verdicts and takes a fresh ticket, which in
* turn keeps any still-in-flight older detection from clobbering it.
*
* A dropped write is not an error: the caller still gets the value it derived,
* it simply isn't pinned for later callers.
*/
export function cacheManagerApi(base, api, guard) {
if (guard) {
const reason = guard.epoch !== epoch
? "invalidated in flight"
: guard.seq !== undefined && guard.seq < lastCommittedSeq
? "overtaken by a later-started detection"
: undefined;
if (reason) {
logger.debug("Dropping a Manager API dialect commit", {
base,
api,
reason,
guard,
epoch,
lastCommittedSeq,
});
return;
}
}
cache = { base, api, at: monotonicNow() };
lastCommittedSeq = guard?.seq ?? ++seqCounter;
}
/**
* Forget the detected dialect so the next Manager call re-probes the live
* server. Call this whenever the connected ComfyUI is (or may have been)
* restarted — a restart is exactly when the Manager generation can change under
* a stable URL (#646) — and when a Manager response proves the cached dialect
* wrong.
*/
export function resetManagerApiCache(reason) {
const was = cache?.api;
cache = null;
// A reset is new information about the server, so the re-check cooldown (a
// "nothing changed" verdict about the PREVIOUS state) no longer applies.
recheckCooldown = null;
epoch++;
logger.debug("Manager API dialect cache reset", { reason, was, epoch });
}
/** @internal — test hook: pin a dialect for `base`, or clear the cache. */
export function setManagerApiCacheForTests(base, api) {
resetManagerApiCache("test");
if (api)
cacheManagerApi(base, api);
}
//# sourceMappingURL=manager-api-cache.js.map