fix(config): report settings persistence failures (#220)

Settings saves now go through one serialized config store
(py/config_store.py). A commit re-reads config.json under a process
lock, applies the change to a copy and replaces the file atomically
(temp file beside it, fsync, close, os.replace), so a failed write
leaves the old file in place.

POST /prompt_manager/settings commits first and only then publishes
the values at runtime. A write failure is 500 config-write-failed, an
unparseable config.json is 409 config-unreadable (the file is kept
instead of replaced) and a stale expected_revision is 409
settings-changed. A save reports persisted, revision, runtime_applied
and restart_required_fields; GET adds the revision. Only submitted keys
are written; other sections and the CivitAI key are kept.
This commit is contained in:
Vito Sansevero
2026-10-22 19:02:57 -07:00
parent a8aff3a39d
commit fba5903049
4 changed files with 744 additions and 61 deletions
+167 -7
View File
@@ -5,6 +5,7 @@ database and a stub ``folder_paths`` module, so the tests run without ComfyUI.
"""
import asyncio
import hashlib
import json
import os
import shutil
@@ -1059,14 +1060,29 @@ class TestSettingsMisc(AdminAPITestCase):
saved["gallery"]["monitoring"]["directories"], [self._root(self.output_dir)]
)
async def test_save_replaces_a_corrupt_config_file(self):
with open(self.config_path, "w") as f:
f.write("{not json")
async def test_save_refuses_a_corrupt_config_file(self):
# #220: a config.json that cannot be parsed is the user's file, never
# silently replaced; the save is refused and nothing is published.
Path(self.config_path).write_bytes(b"{not json")
before_timeout = PromptManagerConfig.RESULT_TIMEOUT
resp = await self.client.request(
"POST", "/prompt_manager/settings", json={"result_timeout": 4}
)
self.assertEqual(resp.status, 200)
self.assertEqual(self._read_config()["web_ui"]["result_timeout"], 4)
body = await resp.json()
self.assertEqual((resp.status, body["code"]), (409, "config-unreadable"))
self.assertIs(body["success"], False)
self.assertIs(body["persisted"], False)
self.assertEqual(PromptManagerConfig.RESULT_TIMEOUT, before_timeout)
self.assertEqual(Path(self.config_path).read_bytes(), b"{not json")
async def test_get_settings_reports_an_unreadable_config(self):
Path(self.config_path).write_bytes(b"{not json")
resp = await self.client.request("GET", "/prompt_manager/settings")
body = await resp.json()
self.assertEqual(resp.status, 200) # the legacy page still loads
self.assertIsNone(body["revision"])
self.assertEqual(body["config_error"], "config-unreadable")
self.assertIn("result_timeout", body["settings"])
async def test_saved_config_file_is_private_to_the_user(self):
import stat
@@ -1107,12 +1123,156 @@ class TestSettingsMisc(AdminAPITestCase):
self.assertEqual(resp.status, 200)
self.assertFalse(data["restart_required"])
async def test_unwritable_config_path_still_succeeds(self):
os.environ[CONFIG_PATH_ENV] = os.path.join(self.tmpdir, "missing", "c.json")
async def test_unwritable_config_path_reports_failure_without_runtime_change(self):
# #220: the parent is a file, so config.json cannot be written. The
# save reports the failure and publishes nothing.
blocker = Path(self.tmpdir) / "not-a-dir"
blocker.write_text("x")
os.environ[CONFIG_PATH_ENV] = str(blocker / "c.json")
GalleryConfig.MONITORING_DIRECTORIES = []
before_timeout = PromptManagerConfig.RESULT_TIMEOUT
with self.assertLogs("prompt_manager.api", level="ERROR"):
resp = await self.client.request(
"POST",
"/prompt_manager/settings",
json={
"result_timeout": before_timeout + 1,
"gallery_root_paths": [self._root(self.output_dir)],
},
)
body = await resp.json()
self.assertEqual(
(resp.status, body["code"], body["persisted"]),
(500, "config-write-failed", False),
)
self.assertIs(body["success"], False)
self.assertIs(body["runtime_applied"], False)
self.assertEqual(PromptManagerConfig.RESULT_TIMEOUT, before_timeout)
self.assertEqual(GalleryConfig.MONITORING_DIRECTORIES, [])
self.assertEqual(blocker.read_text(), "x")
async def test_save_reports_the_commit(self):
resp = await self.client.request(
"POST", "/prompt_manager/settings", json={"result_timeout": 3}
)
body = await resp.json()
self.assertEqual(resp.status, 200)
self.assertEqual(body["message"], "Settings saved successfully")
self.assertIs(body["persisted"], True)
self.assertIs(body["runtime_applied"], True)
self.assertIs(body["restart_required"], False)
self.assertEqual(body["restart_required_fields"], [])
digest = hashlib.sha256(Path(self.config_path).read_bytes()).hexdigest()
self.assertEqual(body["revision"], "sha256:" + digest)
async def test_changed_roots_name_the_restart_field(self):
resp = await self.client.request(
"POST",
"/prompt_manager/settings",
json={"gallery_root_paths": [self._root(self.output_dir)]},
)
body = await resp.json()
self.assertEqual(resp.status, 200)
self.assertIs(body["restart_required"], True)
self.assertEqual(body["restart_required_fields"], ["gallery_root_paths"])
async def test_runtime_publication_failure_after_commit_is_reported(self):
# The values are durable; the response must not claim they are live.
self.api.reset_gallery_roots_cache = _raise
with self.assertLogs("prompt_manager.api", level="ERROR"):
resp = await self.client.request(
"POST",
"/prompt_manager/settings",
json={"gallery_root_paths": [self._root(self.output_dir)]},
)
body = await resp.json()
self.assertEqual(resp.status, 200)
self.assertIs(body["persisted"], True)
self.assertIs(body["runtime_applied"], False)
self.assertIs(body["restart_required"], True)
self.assertEqual(body["restart_required_fields"], ["gallery_root_paths"])
self.assertEqual(
self._read_config()["gallery"]["monitoring"]["directories"],
[self._root(self.output_dir)],
)
async def test_get_settings_reports_revision(self):
resp = await self.client.request("GET", "/prompt_manager/settings")
body = await resp.json()
self.assertEqual(body["revision"], "absent")
self.assertNotIn("config_error", body)
self._write_config({"web_ui": {"result_timeout": 2}})
body = await (
await self.client.request("GET", "/prompt_manager/settings")
).json()
digest = hashlib.sha256(Path(self.config_path).read_bytes()).hexdigest()
self.assertEqual(body["revision"], "sha256:" + digest)
async def test_native_save_with_stale_revision_is_409(self):
self._write_config({"web_ui": {"result_timeout": 2}})
body = await (
await self.client.request("GET", "/prompt_manager/settings")
).json()
seen = body["revision"]
self._write_config({"web_ui": {"result_timeout": 6}}) # another writer
before = Path(self.config_path).read_bytes()
before_timeout = PromptManagerConfig.RESULT_TIMEOUT
resp = await self.client.request(
"POST",
"/prompt_manager/settings",
json={"result_timeout": 9, "expected_revision": seen},
)
body = await resp.json()
self.assertEqual((resp.status, body["code"]), (409, "settings-changed"))
self.assertIs(body["persisted"], False)
digest = hashlib.sha256(before).hexdigest()
self.assertEqual(body["revision"], "sha256:" + digest)
self.assertEqual(Path(self.config_path).read_bytes(), before)
self.assertEqual(PromptManagerConfig.RESULT_TIMEOUT, before_timeout)
async def test_native_save_with_current_revision_commits(self):
body = await (
await self.client.request("GET", "/prompt_manager/settings")
).json()
resp = await self.client.request(
"POST",
"/prompt_manager/settings",
json={"result_timeout": 9, "expected_revision": body["revision"]},
)
self.assertEqual(resp.status, 200)
self.assertEqual(self._read_config()["web_ui"]["result_timeout"], 9)
async def test_expected_revision_must_be_a_string(self):
resp = await self.client.request(
"POST",
"/prompt_manager/settings",
json={"result_timeout": 9, "expected_revision": 5},
)
self.assertEqual(resp.status, 400)
self.assertFalse(os.path.exists(self.config_path))
async def test_legacy_partial_update_without_revision_still_merges(self):
self._write_config(
{
"integrations": {"lora_manager": {"civitai_api_key": "secret"}},
"web_ui": {"result_timeout": 1, "webui_display_mode": "popup"},
"custom_section": {"keep": True},
}
)
resp = await self.client.request(
"POST", "/prompt_manager/settings", json={"result_timeout": 8}
)
self.assertEqual(resp.status, 200)
self.assertNotIn("secret", await resp.text())
saved = self._read_config()
self.assertEqual(
saved["integrations"]["lora_manager"]["civitai_api_key"], "secret"
)
self.assertEqual(saved["custom_section"], {"keep": True})
self.assertEqual(saved["web_ui"]["result_timeout"], 8)
self.assertEqual(saved["web_ui"]["webui_display_mode"], "popup") # not sent
async def test_save_unexpected_error_is_500(self):
self.api._parse_gallery_roots = _raise
+272
View File
@@ -0,0 +1,272 @@
"""Tests for the serialized config store (py/config_store.py, #220).
Every test works on a config.json inside its own temporary directory; the
repository's config.json (live user data) is never read or written.
"""
import hashlib
import json
import os
import shutil
import sys
import tempfile
import threading
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
_mock_server = MagicMock()
_mock_server.PromptServer.instance.routes = MagicMock()
sys.modules.setdefault("server", _mock_server)
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from py import config_store # noqa: E402
from py.config_store import ( # noqa: E402
ABSENT_REVISION,
ConfigStore,
ConfigUnreadable,
RevisionConflict,
get_config_store,
revision_of,
)
try:
from tests.open_handles import assert_closed # noqa: E402
except ImportError: # discovered with tests/ as the top-level directory
from open_handles import assert_closed # noqa: E402
class ConfigStoreTests(unittest.TestCase):
def setUp(self):
self.tmpdir = os.path.realpath(tempfile.mkdtemp())
self.path = os.path.join(self.tmpdir, "config.json")
self.store = ConfigStore(lambda: self.path)
def tearDown(self):
try:
assert_closed(self, self.tmpdir)
finally:
shutil.rmtree(self.tmpdir, ignore_errors=True)
def _write(self, data):
Path(self.path).write_text(json.dumps(data), encoding="utf-8")
def _bytes(self):
return Path(self.path).read_bytes()
def _leftovers(self):
return sorted(name for name in os.listdir(self.tmpdir) if name != "config.json")
# ── read ──────────────────────────────────────────────────────────
def test_read_of_a_missing_file_is_empty_and_absent(self):
self.assertEqual(self.store.read(), ({}, ABSENT_REVISION))
def test_read_reports_the_sha256_of_the_file_bytes(self):
self._write({"web_ui": {"result_timeout": 3}})
data, revision = self.store.read()
self.assertEqual(data, {"web_ui": {"result_timeout": 3}})
expected = "sha256:" + hashlib.sha256(self._bytes()).hexdigest()
self.assertEqual(revision, expected)
self.assertEqual(revision_of(self._bytes()), expected)
def test_non_object_config_is_unreadable(self):
self._write([1, 2])
with self.assertRaises(ConfigUnreadable):
self.store.read()
def test_unreadable_path_is_unreadable(self):
os.mkdir(self.path) # a directory where the file should be
with self.assertRaises(ConfigUnreadable):
self.store.read()
# ── commit ────────────────────────────────────────────────────────
def test_commit_preserves_unknown_sections_and_secret(self):
self._write(
{
"custom_section": {"keep": True},
"integrations": {"lora_manager": {"civitai_api_key": "secret"}},
"web_ui": {"result_timeout": 1, "show_test_button": True},
}
)
def mutate(config):
config.setdefault("web_ui", {})["result_timeout"] = 7
return config
saved, revision = self.store.commit(mutate, expected_revision=None)
on_disk = json.loads(self._bytes())
self.assertEqual(saved, on_disk)
self.assertEqual(on_disk["custom_section"], {"keep": True})
self.assertEqual(
on_disk["integrations"]["lora_manager"]["civitai_api_key"], "secret"
)
self.assertEqual(
on_disk["web_ui"], {"result_timeout": 7, "show_test_button": True}
)
self.assertEqual(revision, revision_of(self._bytes()))
self.assertEqual(self.store.read(), (on_disk, revision))
def test_commit_creates_a_missing_file_and_its_parent(self):
self.path = os.path.join(self.tmpdir, "nested", "config.json")
saved, revision = self.store.commit(
lambda config: {**config, "a": 1}, expected_revision=ABSENT_REVISION
)
self.assertEqual(saved, {"a": 1})
self.assertEqual(json.loads(Path(self.path).read_text()), {"a": 1})
self.assertNotEqual(revision, ABSENT_REVISION)
def test_mutate_works_on_a_copy(self):
self._write({"web_ui": {"result_timeout": 1}})
seen = {}
def mutate(config):
seen["config"] = config
config["web_ui"]["result_timeout"] = 2
raise RuntimeError("validation failed late")
before = self._bytes()
with self.assertRaises(RuntimeError):
self.store.commit(mutate, expected_revision=None)
self.assertEqual(self._bytes(), before)
self.assertEqual(self.store.read()[0], {"web_ui": {"result_timeout": 1}})
def test_mutate_must_return_an_object(self):
self._write({"a": 1})
before = self._bytes()
with self.assertRaises(TypeError):
self.store.commit(lambda config: None, expected_revision=None)
self.assertEqual(self._bytes(), before)
def test_malformed_existing_config_is_refused_and_untouched(self):
Path(self.path).write_bytes(b"{not json")
calls = []
with self.assertRaises(ConfigUnreadable):
self.store.commit(
lambda config: calls.append(config) or config, expected_revision=None
)
self.assertEqual(calls, [])
self.assertEqual(self._bytes(), b"{not json")
self.assertEqual(self._leftovers(), [])
def test_write_failure_leaves_bytes_and_raises(self):
self._write({"a": 1})
before = self._bytes()
with patch.object(config_store.os, "fsync", side_effect=OSError("disk full")):
with self.assertRaises(OSError):
self.store.commit(lambda c: {**c, "b": 2}, expected_revision=None)
self.assertEqual(self._bytes(), before)
self.assertEqual(self._leftovers(), [])
def test_unwritable_directory_raises_oserror(self):
blocker = os.path.join(self.tmpdir, "afile")
Path(blocker).write_text("x")
self.path = os.path.join(blocker, "config.json") # parent is a file
with self.assertRaises(OSError):
self.store.commit(lambda c: {"a": 1}, expected_revision=None)
def test_stale_revision_conflicts_without_write(self):
self._write({"a": 1})
_data, revision = self.store.read()
self._write({"a": 2}) # another writer got there first
before = self._bytes()
calls = []
with self.assertRaises(RevisionConflict) as caught:
self.store.commit(
lambda c: calls.append(c) or c, expected_revision=revision
)
self.assertEqual(caught.exception.current, revision_of(before))
self.assertEqual(calls, [])
self.assertEqual(self._bytes(), before)
def test_matching_revision_commits(self):
self._write({"a": 1})
_data, revision = self.store.read()
saved, _ = self.store.commit(
lambda c: {**c, "b": 2}, expected_revision=revision
)
self.assertEqual(saved, {"a": 1, "b": 2})
def test_temp_file_removed_after_replace_failure(self):
self._write({"a": 1})
before = self._bytes()
with patch.object(
config_store.os, "replace", side_effect=PermissionError("locked")
):
with self.assertRaises(OSError):
self.store.commit(lambda c: {**c, "b": 2}, expected_revision=None)
self.assertEqual(self._bytes(), before)
self.assertEqual(self._leftovers(), [])
@unittest.skipUnless(os.name == "posix", "POSIX file modes")
def test_committed_file_is_private_to_the_user(self):
import stat
self._write({"a": 1})
os.chmod(self.path, 0o644)
self.store.commit(lambda c: c, expected_revision=None)
self.assertEqual(stat.S_IMODE(os.stat(self.path).st_mode), 0o600)
def test_concurrent_commits_serialize(self):
self._write({"keep": True})
inside = threading.Event()
release = threading.Event()
errors = []
def slow(config):
inside.set()
release.wait(10) # hold the lock while the second commit waits
config["first"] = 1
return config
def fast(config):
config["second"] = 2
return config
def run(mutate):
try:
self.store.commit(mutate, expected_revision=None)
except Exception as exc: # pragma: no cover - reported below
errors.append(exc)
first = threading.Thread(target=run, args=(slow,))
first.start()
self.assertTrue(inside.wait(10))
second = threading.Thread(target=run, args=(fast,))
second.start()
release.set()
first.join(10)
second.join(10)
self.assertEqual(errors, [])
self.assertEqual(
json.loads(self._bytes()), {"keep": True, "first": 1, "second": 2}
)
def test_handles_closed_after_commit(self):
self._write({"a": 1})
self.store.read()
self.store.commit(lambda c: {**c, "b": 2}, expected_revision=None)
assert_closed(self, self.tmpdir)
os.unlink(self.path) # Windows refuses this while a handle is open
self.assertEqual(self._leftovers(), [])
class SingletonTests(unittest.TestCase):
def test_get_config_store_is_one_process_store_on_the_configured_path(self):
store = get_config_store()
self.assertIs(store, get_config_store())
tmpdir = tempfile.mkdtemp()
try:
custom = os.path.join(tmpdir, "c.json")
with patch.dict(os.environ, {"PROMPT_MANAGER_CONFIG_PATH": custom}):
self.assertEqual(store.path, custom)
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
if __name__ == "__main__":
unittest.main()