From d1014a5f9248c57259e926f05367d89fe30b5f42 Mon Sep 17 00:00:00 2001 From: Simon Pinfold Date: Tue, 28 Jul 2026 05:44:53 +1200 Subject: [PATCH] feat(assets): add --disable-assets flag to turn the assets system off With --enable-assets removed and the assets system always on, there is no supported way to run without it. Add an explicit opt-out for the rollout bake period: cloud multi-tenant sidecars need a guaranteed off-switch, and QA needs a clean-fallback configuration. When --disable-assets is passed: - asset API routes are registered but disabled, returning a structured 503 - database initialization is skipped entirely (no sqlite file is created; the database currently has no non-asset users) - the background seeder is disabled, covering startup, /object_info and post-execution enrich scans, and output registration - workflow output enrichment and /upload/image asset registration are skipped - the assets feature flag reports false, and supports_model_type_tags follows it since model_type tags are an assets-API capability The hidden --enable-assets no-op remains accepted for launcher compatibility; --disable-assets takes precedence since the former gates nothing. --- comfy/cli_args.py | 1 + comfy_api/feature_flags.py | 4 + comfy_execution/asset_enrichment.py | 12 +- main.py | 4 + server.py | 38 +++--- tests-unit/assets_test/test_disabled_mode.py | 118 ++++++++++++++++++ .../execution_test/test_enrich_output.py | 18 ++- tests-unit/feature_flags_test.py | 19 ++- tests-unit/websocket_feature_flags_test.py | 8 +- 9 files changed, 197 insertions(+), 25 deletions(-) create mode 100644 tests-unit/assets_test/test_disabled_mode.py diff --git a/comfy/cli_args.py b/comfy/cli_args.py index 2e35dc679..fb96ab2c2 100644 --- a/comfy/cli_args.py +++ b/comfy/cli_args.py @@ -243,6 +243,7 @@ parser.add_argument("--database-url", type=str, default=f"sqlite:///{database_de # Deprecated no-op: the asset system is now always enabled. Kept (hidden) so that # existing launchers/containers still passing --enable-assets don't fail argparse. parser.add_argument("--enable-assets", action="store_true", help=argparse.SUPPRESS) +parser.add_argument("--disable-assets", action="store_true", help="Disable the assets system: asset API routes return 503, the asset database is not initialized, no background scanning runs, and uploads and workflow outputs are not registered as assets.") parser.add_argument("--enable-asset-hashing", action="store_true", help="Compute blake3 content hashes when scanning assets. Hashing enables future asset-portability features (deduplication, cross-machine model resolution) but adds startup cost and per-output cost on large models directories. Off by default; enable to opt in.") parser.add_argument("--feature-flag", type=str, action='append', default=[], metavar="KEY[=VALUE]", help="Set a server feature flag. Use KEY=VALUE to set an explicit value, or bare KEY to set it to true. Can be specified multiple times. Boolean values (true/false) and numbers are auto-converted. Examples: --feature-flag show_signin_button=true or --feature-flag show_signin_button") parser.add_argument("--list-feature-flags", action="store_true", help="Print the registry of known CLI-settable feature flags as JSON and exit.") diff --git a/comfy_api/feature_flags.py b/comfy_api/feature_flags.py index 931add361..58ecfc1fc 100644 --- a/comfy_api/feature_flags.py +++ b/comfy_api/feature_flags.py @@ -172,4 +172,8 @@ def get_server_features() -> dict[str, Any]: features["assets"] = assets_enabled() except Exception: features["assets"] = False + if not features["assets"]: + # model_type tags are an assets-API capability; don't advertise them + # when the assets backend is disabled or unavailable. + features["supports_model_type_tags"] = False return features diff --git a/comfy_execution/asset_enrichment.py b/comfy_execution/asset_enrichment.py index 7c851cd6a..9d46ce34c 100644 --- a/comfy_execution/asset_enrichment.py +++ b/comfy_execution/asset_enrichment.py @@ -6,11 +6,15 @@ import os def enrich_output_with_assets(output_ui: dict) -> dict: """Register file-type output entries as assets and inject their ``id``. - Runs at output-processing time, once per produced output. Returns a new - dict; entries without a resolvable on-disk file path are left unchanged. - Errors are caught per-entry so a failure never blocks execution or the - other entries. + Runs at output-processing time, once per produced output, unless + --disable-assets is set. Returns a new dict; entries without a resolvable + on-disk file path are left unchanged. Errors are caught per-entry so a + failure never blocks execution or the other entries. """ + from comfy.cli_args import args + if args.disable_assets: + return output_ui + import folder_paths from app.assets.services.ingest import register_file_in_place, DependencyMissingError diff --git a/main.py b/main.py index 24a75cf78..4302e7709 100644 --- a/main.py +++ b/main.py @@ -459,6 +459,10 @@ def cleanup_temp(): def setup_database(): + if args.disable_assets: + logging.info("Assets system disabled via --disable-assets; skipping database initialization and asset scanning.") + asset_seeder.disable() + return try: if dependencies_available(): init_db() diff --git a/server.py b/server.py index a4c7d7204..231e1a6f9 100644 --- a/server.py +++ b/server.py @@ -253,7 +253,12 @@ class PromptServer(): else args.front_end_root ) logging.info(f"[Prompt Server] web root: {self.web_root}") - register_assets_routes(self.app, self.user_manager) + if args.disable_assets: + # Register the routes without enabling them so /api/assets/* returns + # a structured 503 rather than a 404. + register_assets_routes(self.app) + else: + register_assets_routes(self.app, self.user_manager) routes = web.RouteTableDef() self.routes = routes self.last_node_id = None @@ -435,21 +440,22 @@ class PromptServer(): resp = {"name" : filename, "subfolder": subfolder, "type": image_upload_type} - try: - tag = image_upload_type if image_upload_type in ("input", "output") else "input" - tags = [tag] - tags.extend(get_known_subfolder_tags(subfolder)) - result = register_file_in_place(abs_path=filepath, name=filename, tags=tags) - resp["asset"] = { - "id": result.ref.id, - "name": result.ref.name, - "asset_hash": result.asset.hash, - "size": result.asset.size_bytes, - "mime_type": result.asset.mime_type, - "tags": result.tags, - } - except Exception: - logging.warning("Failed to register uploaded image as asset", exc_info=True) + if not args.disable_assets: + try: + tag = image_upload_type if image_upload_type in ("input", "output") else "input" + tags = [tag] + tags.extend(get_known_subfolder_tags(subfolder)) + result = register_file_in_place(abs_path=filepath, name=filename, tags=tags) + resp["asset"] = { + "id": result.ref.id, + "name": result.ref.name, + "asset_hash": result.asset.hash, + "size": result.asset.size_bytes, + "mime_type": result.asset.mime_type, + "tags": result.tags, + } + except Exception: + logging.warning("Failed to register uploaded image as asset", exc_info=True) return web.json_response(resp) else: diff --git a/tests-unit/assets_test/test_disabled_mode.py b/tests-unit/assets_test/test_disabled_mode.py new file mode 100644 index 000000000..95b9f16c2 --- /dev/null +++ b/tests-unit/assets_test/test_disabled_mode.py @@ -0,0 +1,118 @@ +"""Boot ComfyUI with --disable-assets and verify the assets system is inert.""" +import contextlib +import socket +import subprocess +import sys +import time +from pathlib import Path + +import pytest +import requests + + +@pytest.fixture(autouse=True) +def autoclean_unit_test_assets(): + """Override the package-level autouse cleaner: it boots the regular + (assets-enabled) server, which this module does not need.""" + yield + + +def _free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def _wait_assets_disabled(base: str, timeout: float = 90.0) -> None: + start = time.time() + last_err = None + while time.time() - start < timeout: + try: + r = requests.get(base + "/api/assets", timeout=5) + if r.status_code == 503: + return + last_err = RuntimeError(f"unexpected status {r.status_code}") + except Exception as e: + last_err = e + time.sleep(0.25) + raise RuntimeError(f"ComfyUI HTTP did not become ready: {last_err}") + + +@pytest.fixture(scope="module") +def disabled_comfy(tmp_path_factory: pytest.TempPathFactory): + """Boot a ComfyUI subprocess with --disable-assets. + + Yields (base_url, db_path) where db_path is where the sqlite database + would be created if the assets system initialized it. + """ + base_dir = tmp_path_factory.mktemp("comfyui-assets-disabled") + for sub in ("models", "custom_nodes", "input", "output", "temp", "user"): + (base_dir / sub).mkdir(parents=True, exist_ok=True) + db_path = base_dir / "assets-disabled.sqlite3" + + logs_dir = base_dir / "logs" + logs_dir.mkdir(exist_ok=True) + out_log = open(logs_dir / "stdout.log", "w", buffering=1) + err_log = open(logs_dir / "stderr.log", "w", buffering=1) + + comfy_root = Path(__file__).resolve().parent.parent.parent + port = _free_port() + proc = subprocess.Popen( + args=[ + sys.executable, + "main.py", + f"--base-directory={str(base_dir)}", + f"--database-url=sqlite:///{db_path}", + "--disable-assets", + "--listen", + "127.0.0.1", + "--port", + str(port), + "--cpu", + ], + stdout=out_log, + stderr=err_log, + cwd=str(comfy_root), + ) + + base_url = f"http://127.0.0.1:{port}" + try: + _wait_assets_disabled(base_url) + yield base_url, db_path + finally: + if proc.poll() is None: + with contextlib.suppress(Exception): + proc.terminate() + proc.wait(timeout=15) + out_log.close() + err_log.close() + + +def test_assets_routes_return_503(disabled_comfy): + base_url, _ = disabled_comfy + for path in ("/api/assets", "/api/tags", "/api/assets/seed/status"): + r = requests.get(base_url + path, timeout=30) + assert r.status_code == 503, (path, r.text) + assert r.json()["error"]["code"] == "SERVICE_DISABLED" + + +def test_database_not_created(disabled_comfy): + _, db_path = disabled_comfy + assert not db_path.exists() + + +def test_seed_scan_rejected(disabled_comfy): + base_url, _ = disabled_comfy + r = requests.post(base_url + "/api/assets/seed", json={"roots": ["models"]}, timeout=30) + assert r.status_code == 503 + + +def test_upload_image_skips_asset_registration(disabled_comfy): + base_url, db_path = disabled_comfy + files = {"image": ("disabled-mode-test.png", b"\x89PNG fake bytes", "image/png")} + r = requests.post(base_url + "/upload/image", files=files, timeout=30) + assert r.status_code == 200, r.text + body = r.json() + assert body["name"] == "disabled-mode-test.png" + assert "asset" not in body + assert not db_path.exists() diff --git a/tests-unit/execution_test/test_enrich_output.py b/tests-unit/execution_test/test_enrich_output.py index d67141f8f..c60243f2f 100644 --- a/tests-unit/execution_test/test_enrich_output.py +++ b/tests-unit/execution_test/test_enrich_output.py @@ -1,9 +1,16 @@ """Tests for enrich_output_with_assets in comfy_execution/asset_enrichment.py.""" import os +import types import unittest from unittest.mock import MagicMock, patch +def _make_args(disable_assets: bool): + a = types.SimpleNamespace() + a.disable_assets = disable_assets + return a + + def _make_register_result(ref_id="ref-id-2"): result = MagicMock() result.ref.id = ref_id @@ -15,8 +22,9 @@ def _make_register_result(ref_id="ref-id-2"): _DEFAULT_BASE = os.path.join(__import__("tempfile").gettempdir(), "asset-enrichment-test-base") -def _mocked_modules(*, register_file_in_place=None, directory=_DEFAULT_BASE): +def _mocked_modules(*, disable_assets=False, register_file_in_place=None, directory=_DEFAULT_BASE): return { + "comfy.cli_args": MagicMock(args=_make_args(disable_assets)), "folder_paths": MagicMock(get_directory_by_type=MagicMock(return_value=directory)), "app.assets.services.ingest": MagicMock( register_file_in_place=register_file_in_place or MagicMock(return_value=_make_register_result()), @@ -25,9 +33,10 @@ def _mocked_modules(*, register_file_in_place=None, directory=_DEFAULT_BASE): } -def _call(output_ui, *, file_exists=True, register_result=None, directory=_DEFAULT_BASE): +def _call(output_ui, *, disable_assets=False, file_exists=True, register_result=None, directory=_DEFAULT_BASE): register_mock = MagicMock(return_value=register_result or _make_register_result()) mocked = _mocked_modules( + disable_assets=disable_assets, register_file_in_place=register_mock, directory=directory, ) @@ -44,6 +53,11 @@ def _call(output_ui, *, file_exists=True, register_result=None, directory=_DEFAU class TestEnrichOutputWithAssets(unittest.TestCase): + def test_disabled_returns_unchanged(self): + output = {"images": [{"filename": "a.png", "subfolder": "", "type": "output"}]} + result = _call(output, disable_assets=True) + self.assertNotIn("id", result["images"][0]) + def test_non_list_value_passed_through(self): output = {"text": "hello"} result = _call(output) diff --git a/tests-unit/feature_flags_test.py b/tests-unit/feature_flags_test.py index a436ab1ec..0766c8de8 100644 --- a/tests-unit/feature_flags_test.py +++ b/tests-unit/feature_flags_test.py @@ -1,5 +1,7 @@ """Tests for feature flags functionality.""" +from unittest.mock import patch + import pytest from comfy_api.feature_flags import ( @@ -26,7 +28,8 @@ class TestFeatureFlags: def test_get_server_features_contains_expected_flags(self): """Test that server features contain expected flags.""" - features = get_server_features() + with patch("app.assets.api.routes.assets_enabled", return_value=True): + features = get_server_features() assert "supports_preview_metadata" in features assert features["supports_preview_metadata"] is True assert "supports_model_type_tags" in features @@ -34,6 +37,20 @@ class TestFeatureFlags: assert "max_upload_size" in features assert isinstance(features["max_upload_size"], (int, float)) + def test_assets_flag_reflects_live_availability(self): + """The assets capability mirrors assets_enabled() rather than a static default.""" + with patch("app.assets.api.routes.assets_enabled", return_value=True): + assert get_server_features()["assets"] is True + with patch("app.assets.api.routes.assets_enabled", return_value=False): + assert get_server_features()["assets"] is False + + def test_model_type_tags_follow_assets_availability(self): + """supports_model_type_tags is an assets capability: off when assets are off.""" + with patch("app.assets.api.routes.assets_enabled", return_value=False): + features = get_server_features() + assert features["assets"] is False + assert features["supports_model_type_tags"] is False + def test_get_connection_feature_with_missing_sid(self): """Test getting feature for non-existent session ID.""" sockets_metadata = {} diff --git a/tests-unit/websocket_feature_flags_test.py b/tests-unit/websocket_feature_flags_test.py index 4950bd9d0..609547c48 100644 --- a/tests-unit/websocket_feature_flags_test.py +++ b/tests-unit/websocket_feature_flags_test.py @@ -1,4 +1,6 @@ """Simplified tests for WebSocket feature flags functionality.""" +from unittest.mock import patch + from comfy_api import feature_flags @@ -7,7 +9,8 @@ class TestWebSocketFeatureFlags: def test_server_feature_flags_response(self): """Test server feature flags are properly formatted.""" - features = feature_flags.get_server_features() + with patch("app.assets.api.routes.assets_enabled", return_value=True): + features = feature_flags.get_server_features() # Check expected server features assert "supports_preview_metadata" in features @@ -67,7 +70,8 @@ class TestWebSocketFeatureFlags: assert "supports_preview_metadata" in client_message["data"] # Server response format (what would be sent) - server_features = feature_flags.get_server_features() + with patch("app.assets.api.routes.assets_enabled", return_value=True): + server_features = feature_flags.get_server_features() server_message = { "type": "feature_flags", "data": server_features