UNPKG

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
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