Initial commit: in-process object_info cache + refresh UI
ComfyUI-Tenaciousload speeds up ComfyUI page loads for large model/LoRA collections by caching the slow /api/object_info response in memory and on disk (survives restarts) and serving it gzipped in ~milliseconds, instead of rebuilding it (and freezing the event loop) on every load. Adds a "Refresh Models / LoRAs" menu button, a graph node, and a POST /tenaciousload/refresh endpoint to rebuild the cache after adding or removing models. No external dependencies; no nginx/docker required. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
cache/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Ethanfel
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,72 @@
|
||||
# ComfyUI-Tenaciousload
|
||||
|
||||
Self-contained fix for slow / black-screen ComfyUI loading when you have a huge
|
||||
model/LoRA collection (especially on a network mount). **Just install the pack
|
||||
and restart ComfyUI — no nginx, no docker, no extra port.**
|
||||
|
||||
## The problem
|
||||
ComfyUI's `/api/object_info` enumerates every node's inputs. With thousands of
|
||||
LoRAs (worse on a network mount) it becomes tens of MB and takes **minutes to
|
||||
build on every page load** — and the build **freezes ComfyUI's whole event
|
||||
loop**, so you get a long black screen, worst over a remote network.
|
||||
|
||||
## How this pack fixes it
|
||||
On load it injects an aiohttp **middleware** into ComfyUI that intercepts
|
||||
`/object_info` and `/api/object_info` and:
|
||||
|
||||
- **caches the built response in memory _and_ on disk** (`./cache/`), so it is
|
||||
built once instead of on every load — and the disk copy makes **restarts
|
||||
instant** (no rebuild);
|
||||
- **serves it gzipped in ~milliseconds** (≈85% smaller, independent of any CLI
|
||||
flag);
|
||||
- serves from cache **without running the build**, so the event-loop freeze is
|
||||
gone for normal loads.
|
||||
|
||||
The only time a build runs is the first load after install, or when you
|
||||
explicitly refresh (below).
|
||||
|
||||
## Refreshing after you add / remove models or LoRAs
|
||||
The cache holds the old model lists until you refresh, so new files won't appear
|
||||
until you do one of:
|
||||
|
||||
- **Menu:** `Extensions ▸ 🔄 Refresh Models / LoRAs` (also in the command palette).
|
||||
- **Graph node:** `🔄 Refresh Models/LoRAs (Tenaciousload)` (for automated workflows).
|
||||
- **HTTP:** `POST /tenaciousload/refresh`, then `GET /object_info?nocache=1`.
|
||||
|
||||
A refresh re-walks your model folders (slow over a network mount, ~minutes) — the
|
||||
button shows a "refreshing…" toast meanwhile. Normal loads stay instant.
|
||||
|
||||
## Requirements
|
||||
**None to install.** Only ComfyUI itself (tested on 0.23.0) and Python ≥ 3.8.
|
||||
Everything used is Python stdlib or already bundled with ComfyUI (`aiohttp`,
|
||||
`folder_paths`, `server`). The web button needs no npm packages.
|
||||
|
||||
## Install
|
||||
Clone (or copy) this repo into your ComfyUI `custom_nodes/` folder and restart
|
||||
ComfyUI:
|
||||
|
||||
```bash
|
||||
cd ComfyUI/custom_nodes
|
||||
git clone https://github.com/ethanfel/ComfyUI-Tenaciousload.git
|
||||
# then restart ComfyUI
|
||||
```
|
||||
Nothing to `pip install`. ComfyUI-Manager can also install it from the registry.
|
||||
|
||||
## Verify it's working
|
||||
After restart, load the page once (first time builds + caches), then:
|
||||
```bash
|
||||
curl -s -H 'Accept-Encoding: gzip' -o /dev/null \
|
||||
-w '%{time_total}s | %{size_download} bytes | %header{x-tenaciousload-cache} | %header{content-encoding}\n' \
|
||||
http://127.0.0.1:8188/api/object_info # use your ComfyUI port
|
||||
# expect after the first load: ~0.00Xs | ~10 MB | HIT | gzip
|
||||
```
|
||||
ComfyUI's startup log should show `Tenaciousload: object_info cache middleware installed`.
|
||||
|
||||
## Notes
|
||||
- The disk cache lives in `./cache/` (git-ignored). Delete it, or use the refresh
|
||||
button, to force a rebuild.
|
||||
- An nginx reverse proxy can cache `object_info` at the HTTP layer too, but this
|
||||
pack does it in-process so no extra service, container, or port is needed.
|
||||
|
||||
## License
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
"""
|
||||
ComfyUI-Tenaciousload
|
||||
=====================
|
||||
Self-contained fix for slow / black-screen ComfyUI loading when you have a huge
|
||||
model/LoRA collection (especially on a network mount).
|
||||
|
||||
The cause: /api/object_info is tens of MB and takes minutes to build on EVERY
|
||||
page load (it re-walks the model folders), and the build blocks ComfyUI's event
|
||||
loop the whole time. This pack installs an in-process caching layer that:
|
||||
|
||||
* caches the built object_info in memory AND on disk (so it survives restarts),
|
||||
* serves it gzipped in ~milliseconds instead of rebuilding every load,
|
||||
* exposes a one-click "Refresh Models / LoRAs" button + graph node to rebuild
|
||||
the cache after you add/remove models.
|
||||
|
||||
No nginx, no docker, no extra port. Just install the pack and restart ComfyUI.
|
||||
"""
|
||||
|
||||
import os
|
||||
import gzip
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
import folder_paths
|
||||
from server import PromptServer
|
||||
|
||||
log = logging.getLogger("Tenaciousload")
|
||||
|
||||
WEB_DIRECTORY = "./web"
|
||||
|
||||
# --- cache storage --------------------------------------------------------------
|
||||
_CACHE_DIR = os.path.join(os.path.dirname(__file__), "cache")
|
||||
_RAW_PATH = os.path.join(_CACHE_DIR, "object_info.json")
|
||||
_GZ_PATH = os.path.join(_CACHE_DIR, "object_info.json.gz")
|
||||
_OBJECT_INFO_PATHS = ("/object_info", "/api/object_info")
|
||||
_GZIP_LEVEL = 5
|
||||
|
||||
_lock = threading.Lock()
|
||||
_mem = {"raw": None, "gz": None} # bytes / bytes
|
||||
_disk_loaded = False
|
||||
|
||||
|
||||
def _load_from_disk():
|
||||
"""Populate the in-memory cache from disk once (cheap, makes restarts instant)."""
|
||||
global _disk_loaded
|
||||
_disk_loaded = True
|
||||
try:
|
||||
if os.path.exists(_GZ_PATH):
|
||||
with open(_GZ_PATH, "rb") as f:
|
||||
_mem["gz"] = f.read()
|
||||
if os.path.exists(_RAW_PATH):
|
||||
with open(_RAW_PATH, "rb") as f:
|
||||
_mem["raw"] = f.read()
|
||||
if _mem["gz"] is not None and _mem["raw"] is None:
|
||||
_mem["raw"] = gzip.decompress(_mem["gz"])
|
||||
if _mem["raw"] is not None:
|
||||
log.info("Tenaciousload: loaded object_info cache from disk (%d bytes raw)", len(_mem["raw"]))
|
||||
except Exception as e: # pragma: no cover
|
||||
log.warning("Tenaciousload: failed to load disk cache: %s", e)
|
||||
|
||||
|
||||
def _store(raw_bytes):
|
||||
"""Cache a freshly built object_info body (memory + disk)."""
|
||||
with _lock:
|
||||
gz = gzip.compress(raw_bytes, _GZIP_LEVEL)
|
||||
_mem["raw"] = raw_bytes
|
||||
_mem["gz"] = gz
|
||||
try:
|
||||
os.makedirs(_CACHE_DIR, exist_ok=True)
|
||||
with open(_RAW_PATH, "wb") as f:
|
||||
f.write(raw_bytes)
|
||||
with open(_GZ_PATH, "wb") as f:
|
||||
f.write(gz)
|
||||
except Exception as e: # pragma: no cover
|
||||
log.warning("Tenaciousload: failed to persist disk cache: %s", e)
|
||||
log.info("Tenaciousload: cached object_info (%d bytes raw / %d gz)", len(raw_bytes), len(gz))
|
||||
|
||||
|
||||
def invalidate_object_info_cache():
|
||||
"""Drop the cached object_info so the next request rebuilds it."""
|
||||
with _lock:
|
||||
_mem["raw"] = None
|
||||
_mem["gz"] = None
|
||||
for p in (_RAW_PATH, _GZ_PATH):
|
||||
try:
|
||||
os.remove(p)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except Exception as e: # pragma: no cover
|
||||
log.warning("Tenaciousload: could not delete %s: %s", p, e)
|
||||
|
||||
|
||||
def clear_comfy_model_cache():
|
||||
"""Force ComfyUI to re-scan every model/LoRA folder on the next build."""
|
||||
try:
|
||||
folder_paths.filename_list_cache.clear()
|
||||
except Exception as e: # pragma: no cover
|
||||
log.warning("Tenaciousload: could not clear filename_list_cache: %s", e)
|
||||
try:
|
||||
folder_paths.cache_helper.clear()
|
||||
except Exception as e: # pragma: no cover
|
||||
log.warning("Tenaciousload: could not clear cache_helper: %s", e)
|
||||
|
||||
|
||||
def _serve_cached(request):
|
||||
raw = _mem["raw"]
|
||||
gz = _mem["gz"]
|
||||
accepts_gzip = "gzip" in request.headers.get("Accept-Encoding", "")
|
||||
if accepts_gzip and gz is not None:
|
||||
return web.Response(
|
||||
body=gz, status=200, content_type="application/json",
|
||||
headers={"Content-Encoding": "gzip", "X-Tenaciousload-Cache": "HIT",
|
||||
"Cache-Control": "no-store"},
|
||||
)
|
||||
if raw is None and gz is not None:
|
||||
raw = gzip.decompress(gz)
|
||||
return web.Response(
|
||||
body=raw, status=200, content_type="application/json",
|
||||
headers={"X-Tenaciousload-Cache": "HIT", "Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
@web.middleware
|
||||
async def _object_info_cache_mw(request, handler):
|
||||
# Only touch the full object_info list (not /object_info/{node_class}).
|
||||
if request.method != "GET" or request.path not in _OBJECT_INFO_PATHS:
|
||||
return await handler(request)
|
||||
|
||||
global _disk_loaded
|
||||
if not _disk_loaded:
|
||||
_load_from_disk()
|
||||
|
||||
nocache = "nocache" in request.query
|
||||
if not nocache and _mem["raw"] is not None:
|
||||
return _serve_cached(request)
|
||||
|
||||
# MISS (or forced refresh): build via the real handler, then cache the body.
|
||||
resp = await handler(request)
|
||||
try:
|
||||
body = getattr(resp, "body", None)
|
||||
if resp.status == 200 and isinstance(body, (bytes, bytearray)) and len(body) > 0:
|
||||
_store(bytes(body))
|
||||
return _serve_cached(request)
|
||||
except Exception as e: # pragma: no cover
|
||||
log.warning("Tenaciousload: caching skipped: %s", e)
|
||||
return resp
|
||||
|
||||
|
||||
def _install_middleware():
|
||||
try:
|
||||
app = PromptServer.instance.app
|
||||
app.middlewares.insert(0, _object_info_cache_mw)
|
||||
log.info("Tenaciousload: object_info cache middleware installed")
|
||||
except Exception as e:
|
||||
log.error("Tenaciousload: could not install cache middleware (loads will be slow): %s", e)
|
||||
|
||||
|
||||
_install_middleware()
|
||||
|
||||
|
||||
# --- refresh API ----------------------------------------------------------------
|
||||
@PromptServer.instance.routes.post("/tenaciousload/refresh")
|
||||
async def _refresh(request):
|
||||
clear_comfy_model_cache()
|
||||
invalidate_object_info_cache()
|
||||
log.info("Tenaciousload: caches cleared via refresh endpoint")
|
||||
return web.json_response({"status": "ok"})
|
||||
|
||||
|
||||
# --- optional graph node (for workflow automation) ------------------------------
|
||||
class _AnyType(str):
|
||||
def __ne__(self, other):
|
||||
return False
|
||||
|
||||
|
||||
ANY = _AnyType("*")
|
||||
|
||||
|
||||
class TenaciousloadRefresh:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {"optional": {"trigger": (ANY, {})}}
|
||||
|
||||
RETURN_TYPES = (ANY,)
|
||||
RETURN_NAMES = ("trigger",)
|
||||
FUNCTION = "refresh"
|
||||
CATEGORY = "Tenaciousload"
|
||||
OUTPUT_NODE = True
|
||||
DESCRIPTION = "Clear ComfyUI's model/LoRA cache and invalidate the object_info cache."
|
||||
|
||||
def refresh(self, trigger=None):
|
||||
clear_comfy_model_cache()
|
||||
invalidate_object_info_cache()
|
||||
return (trigger,)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {"TenaciousloadRefresh": TenaciousloadRefresh}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {"TenaciousloadRefresh": "🔄 Refresh Models/LoRAs (Tenaciousload)"}
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
|
||||
@@ -0,0 +1,14 @@
|
||||
[project]
|
||||
name = "comfyui-tenaciousload"
|
||||
description = "Fast ComfyUI loading behind an nginx cache, with an in-UI button to refresh model/LoRA lists."
|
||||
version = "1.0.0"
|
||||
license = { text = "MIT" }
|
||||
dependencies = []
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://github.com/ethanfel/ComfyUI-Tenaciousload"
|
||||
|
||||
[tool.comfy]
|
||||
PublisherId = "ethanfel"
|
||||
DisplayName = "Tenaciousload"
|
||||
Icon = ""
|
||||
@@ -0,0 +1,9 @@
|
||||
# ComfyUI-Tenaciousload has NO external dependencies.
|
||||
# Everything it uses is either the Python standard library
|
||||
# (os, gzip, json, logging, threading) or already provided by ComfyUI:
|
||||
# - aiohttp (ComfyUI's web server, aiohttp>=3.11.8)
|
||||
# - folder_paths, server (ComfyUI internal modules)
|
||||
#
|
||||
# Requirements to run:
|
||||
# - ComfyUI (any recent version; tested on 0.23.0)
|
||||
# - Python >= 3.8
|
||||
@@ -0,0 +1,55 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { api } from "../../scripts/api.js";
|
||||
|
||||
let busy = false;
|
||||
|
||||
function notify(severity, detail, life = 6000) {
|
||||
const toast = app.extensionManager?.toast;
|
||||
if (toast?.add) {
|
||||
toast.add({ severity, summary: "Tenaciousload", detail, life });
|
||||
} else if (severity === "error") {
|
||||
alert("Tenaciousload: " + detail);
|
||||
}
|
||||
console[severity === "error" ? "error" : "log"]("[Tenaciousload]", detail);
|
||||
}
|
||||
|
||||
async function doRefresh() {
|
||||
if (busy) {
|
||||
notify("warn", "A refresh is already running…");
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
notify("info", "Refreshing models/LoRAs — scanning over the network, this can take a few minutes…", 10000);
|
||||
try {
|
||||
// 1) clear ComfyUI's folder cache + drop the object_info cache.
|
||||
await api.fetchApi("/tenaciousload/refresh", { method: "POST" });
|
||||
// 2) force a fresh object_info build and re-cache it (this is the slow step).
|
||||
await api.fetchApi("/object_info?nocache=1").then((r) => r.arrayBuffer());
|
||||
// 3) update open node dropdowns live if the frontend supports it.
|
||||
try { await app.refreshComboInNodes?.(); } catch (e) { /* non-fatal */ }
|
||||
notify("success", "Done — new models are now available (reload the page if a dropdown still looks stale).", 8000);
|
||||
} catch (e) {
|
||||
notify("error", "Refresh failed: " + (e?.message || e), 10000);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "Tenaciousload.Refresh",
|
||||
commands: [
|
||||
{
|
||||
id: "Tenaciousload.refresh",
|
||||
label: "🔄 Refresh Models / LoRAs",
|
||||
icon: "pi pi-refresh",
|
||||
function: doRefresh,
|
||||
},
|
||||
],
|
||||
// Adds the command under the top "Extensions" menu (and the command palette).
|
||||
menuCommands: [
|
||||
{
|
||||
path: ["Extensions"],
|
||||
commands: ["Tenaciousload.refresh"],
|
||||
},
|
||||
],
|
||||
});
|
||||
Reference in New Issue
Block a user