Files
ComfyAssets-ComfyUI_PromptM…/tests/test_browser_fast_reset.py
T
Vito f5856076ce fix(test): own /dev/shm reset scratch by a flock, not a pid (#419) (#439)
A browser server inside an --ipc=host container shares the host's /dev/shm
but not its PID namespace, so the pid probe saw live servers' scratch folders
as orphans and deleted them. A live owner now holds a flock on owner.lock in
its folder; the kernel drops it when the owner dies, in any namespace. The
cleaner removes only held-named folders whose lock is free and leaves legacy
and staging folders alone. Without flock (Windows) the scratch stays on disk.
2026-10-28 23:12:24 +00:00

446 lines
17 KiB
Python

"""Browser-test resets seed a scratch copy and publish it in one commit (#384)."""
import hashlib
import os
import sqlite3
import subprocess
import sys
import tempfile
import threading
import time
import types
import unittest
from pathlib import Path
from unittest import mock
try:
from tests.open_handles import assert_closed
except ImportError: # discovered with tests/ as the top-level directory
from open_handles import assert_closed
from database.operations import PromptDatabase
from tests.browser import fast_reset
from tests.browser.fast_reset import ScratchSeeder, table_digests
# The owner lock of /dev/shm scratch folders needs POSIX flock (#419).
HAS_FLOCK = unittest.skipUnless(os.name == "posix", "needs POSIX flock")
def digests(db: PromptDatabase) -> dict:
return table_digests(db.model.get_connection())
def add_prompt(db: PromptDatabase, text: str) -> int:
with db.model.get_connection() as conn:
return conn.execute(
"INSERT INTO prompts (text, hash, created_at, updated_at) "
"VALUES (?, ?, '2026-01-01 00:00:00', '2026-01-01 00:00:00')",
(text, hashlib.sha256(text.encode()).hexdigest()),
).lastrowid
def add_image(db: PromptDatabase, prompt_id: int, name: str) -> int:
with db.model.get_connection() as conn:
return conn.execute(
"INSERT INTO generated_images (prompt_id, image_path, filename, "
"generation_time) VALUES (?, ?, ?, '2026-01-01 00:00:00')",
(prompt_id, f"/out/{name}", name),
).lastrowid
def reseed(api) -> str:
"""A seeder in the old style: clears its tables, then many small commits."""
db = api.db
with db.model.get_connection() as conn:
for table in ("generated_images", "prompt_tags", "tags", "prompts"):
conn.execute(f"DELETE FROM {table}")
for index in range(30):
prompt_id = add_prompt(db, f"seeded {index}")
add_image(db, prompt_id, f"seeded-{index}.png")
with db.model.get_connection() as conn:
tag_id = conn.execute(
"INSERT INTO tags (name) VALUES (?)", (f"tag{index}",)
).lastrowid
conn.execute(
"INSERT INTO prompt_tags (prompt_id, tag_id) VALUES (?, ?)",
(prompt_id, tag_id),
)
return "manifest"
class TestScratchSeeder(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name)
self.addCleanup(assert_closed, self, self.tmp.name)
self.live = self.database("live.db")
self.api = types.SimpleNamespace(db=self.live, marker="live api")
self.seeder = ScratchSeeder(self.api, self.root / "scratch", memory_root=None)
self.addCleanup(self.seeder.close)
# Rows from "earlier tests": AUTOINCREMENT positions have moved on.
for index in range(5):
add_image(self.live, add_prompt(self.live, f"old {index}"), f"o{index}")
with self.live.model.get_connection() as conn:
conn.execute("DELETE FROM prompts WHERE text = 'old 4'")
def database(self, name: str) -> PromptDatabase:
db = PromptDatabase(str(self.root / name))
self.addCleanup(db.close_all)
return db
def oracle(self, seed):
"""Run ``seed`` the old way, on a copy of the live database."""
path = self.root / "oracle.db"
target = sqlite3.connect(path)
try:
self.live.model.get_connection().backup(target)
finally:
target.close()
db = self.database("oracle.db")
seed(types.SimpleNamespace(db=db))
return digests(db)
def test_publishes_exactly_what_the_seed_writes_on_live(self):
expected = self.oracle(reseed)
self.assertEqual(self.seeder.run(reseed), "manifest")
self.assertEqual(digests(self.live), expected)
with self.live.model.get_connection() as conn:
first = conn.execute("SELECT MIN(id) FROM prompts").fetchone()[0]
self.assertEqual(first, 6, "AUTOINCREMENT continues as on live")
def test_runs_twice_from_the_state_the_first_run_left(self):
self.seeder.run(reseed)
expected = self.oracle(reseed)
self.seeder.run(reseed)
self.assertEqual(digests(self.live), expected)
def test_tables_the_seed_did_not_change_keep_their_rows(self):
before = digests(self.live)
def rename(api):
with api.db.model.get_connection() as conn:
conn.execute("UPDATE prompts SET text = 'renamed' WHERE id = 1")
self.seeder.run(rename)
after = digests(self.live)
changed = {table for table in after if after[table] != before[table]}
self.assertEqual(changed, {"prompts"})
with self.live.model.get_connection() as conn:
images = conn.execute("SELECT COUNT(*) FROM generated_images").fetchone()
# Replacing prompts' rows never cascades into generated_images.
self.assertEqual(images[0], 4)
def test_the_seed_sees_the_scratch_database_and_the_live_api(self):
seen = {}
def seed(api):
seen["db"] = api.db
seen["marker"] = api.marker
api.note = "set by the seed"
self.seeder.run(seed)
self.assertIsNot(seen["db"], self.live)
self.assertNotEqual(seen["db"].model.db_path, self.live.model.db_path)
self.assertEqual(seen["marker"], "live api")
self.assertEqual(self.api.note, "set by the seed")
self.assertIs(self.api.db, self.live)
def test_cache_drops_wait_until_the_rows_are_published(self):
seen = []
def invalidate_gallery_cache(retire=False):
with self.live.model.get_connection() as conn:
count = conn.execute("SELECT COUNT(*) FROM tags").fetchone()[0]
seen.append((count, retire))
self.api.invalidate_gallery_cache = invalidate_gallery_cache
def seed(api):
reseed(api)
api.invalidate_gallery_cache(retire=True)
self.assertEqual(seen, [], "not dropped while live has the old rows")
self.seeder.run(seed)
self.assertEqual(seen, [(30, True)])
def test_a_failing_seed_drops_no_cache(self):
seen = []
self.api.invalidate_gallery_cache = lambda: seen.append(True)
def broken(api):
api.invalidate_gallery_cache()
raise ValueError("seed failed")
with self.assertRaises(ValueError):
self.seeder.run(broken)
self.assertEqual(seen, [])
def test_a_failing_seed_leaves_live_untouched(self):
before = digests(self.live)
def broken(api):
reseed(api)
raise ValueError("seed failed")
with self.assertRaisesRegex(ValueError, "seed failed"):
self.seeder.run(broken)
self.assertEqual(digests(self.live), before)
self.assertIs(self.api.db, self.live)
def test_a_concurrent_live_commit_fails_the_reset(self):
other = self.database("live.db")
def racing(api):
reseed(api)
add_prompt(other, "written to live meanwhile")
with self.assertRaisesRegex(RuntimeError, "changed while"):
self.seeder.run(racing)
with self.live.model.get_connection() as conn:
texts = [row[0] for row in conn.execute("SELECT text FROM prompts")]
self.assertIn("written to live meanwhile", texts)
self.assertNotIn("seeded 0", texts)
def test_a_write_open_during_the_seed_and_committed_later_fails_it(self):
other = self.database("live.db")
began, finished = threading.Event(), []
def write():
with other.model.get_connection() as conn:
conn.execute("BEGIN IMMEDIATE")
conn.execute(
"INSERT INTO prompts (text, hash) VALUES ('concurrent', 'c')"
)
began.set()
time.sleep(0.5) # the publish waits on this lock meanwhile
other.model.close()
finished.append(True)
writer = threading.Thread(target=write)
def racing(api):
reseed(api)
writer.start()
self.assertTrue(began.wait(10))
with self.assertRaisesRegex(RuntimeError, "changed while"):
self.seeder.run(racing)
writer.join()
self.assertEqual(finished, [True])
with self.live.model.get_connection() as conn:
texts = [row[0] for row in conn.execute("SELECT text FROM prompts")]
self.assertIn("concurrent", texts)
self.assertNotIn("seeded 0", texts)
self.assert_live_connection_restored()
def test_a_failed_publish_leaves_live_and_its_connection_as_they_were(self):
before = digests(self.live)
columns = fast_reset._columns
def broken_columns(conn, table, *schema):
# The publish asks for the live table's columns without a schema.
return columns(conn, table, *schema) if schema else ["no_such_column"]
with mock.patch.object(fast_reset, "_columns", broken_columns):
with self.assertRaises(sqlite3.OperationalError):
self.seeder.run(reseed)
self.assertEqual(digests(self.live), before)
self.assert_live_connection_restored()
def assert_live_connection_restored(self):
conn = self.live.model.get_connection()
self.assertFalse(conn.in_transaction)
self.assertEqual(conn.execute("PRAGMA foreign_keys").fetchone()[0], 1)
names = [row[1] for row in conn.execute("PRAGMA database_list")]
self.assertEqual(names, ["main"])
def test_the_live_connection_is_left_as_it_was(self):
self.seeder.run(reseed)
conn = self.live.model.get_connection()
self.assertFalse(conn.in_transaction)
self.assertEqual(conn.execute("PRAGMA foreign_keys").fetchone()[0], 1)
names = [row[1] for row in conn.execute("PRAGMA database_list")]
self.assertEqual(names, ["main"])
self.assertEqual(conn.execute("PRAGMA foreign_key_check").fetchall(), [])
def test_on_live_mode_runs_the_seed_on_the_live_api(self):
seeder = ScratchSeeder(
self.api, self.root / "unused", on_live=True, memory_root=self.root
)
self.addCleanup(seeder.close)
expected = self.oracle(reseed)
seen = []
self.assertEqual(
seeder.run(lambda api: seen.append(api) or reseed(api)), "manifest"
)
self.assertIs(seen[0], self.api)
self.assertEqual(digests(self.live), expected)
self.assertFalse((self.root / "unused").exists())
@HAS_FLOCK
def test_the_scratch_lives_under_the_memory_root_when_there_is_one(self):
memory = self.root / "memory"
memory.mkdir()
seeder = ScratchSeeder(self.api, self.root / "fallback", memory_root=memory)
self.addCleanup(seeder.close)
expected = self.oracle(reseed)
seeder.run(reseed)
self.assertEqual(digests(self.live), expected)
self.assertEqual(len(list(memory.glob("pm-reset-*/seed.db"))), 1)
self.assertFalse((self.root / "fallback").exists())
seeder.close()
self.assertEqual(list(memory.iterdir()), [])
@unittest.skipIf(os.name == "posix", "without flock the memory root is unused")
def test_without_flock_the_scratch_stays_on_disk(self):
memory = self.root / "memory"
memory.mkdir()
seeder = ScratchSeeder(self.api, self.root / "fallback", memory_root=memory)
self.addCleanup(seeder.close)
seeder.run(reseed)
self.assertEqual(list(memory.iterdir()), [])
self.assertTrue((self.root / "fallback" / "seed.db").exists())
def test_a_full_memory_root_puts_the_scratch_on_disk(self):
memory = self.root / "memory"
memory.mkdir()
seeder = ScratchSeeder(self.api, self.root / "fallback", memory_root=memory)
self.addCleanup(seeder.close)
full = mock.patch.object(
fast_reset.shutil,
"disk_usage",
return_value=types.SimpleNamespace(free=fast_reset.MEMORY_FREE_BYTES - 1),
)
with full:
seeder.run(reseed)
self.assertEqual(list(memory.iterdir()), [])
self.assertTrue((self.root / "fallback" / "seed.db").exists())
@HAS_FLOCK
def test_the_scratch_folder_is_held_by_a_lock_until_close(self):
memory = self.root / "memory"
memory.mkdir()
seeder = ScratchSeeder(self.api, self.root / "fallback", memory_root=memory)
self.addCleanup(seeder.close)
seeder.run(reseed)
(folder,) = memory.iterdir()
self.assertTrue(folder.name.startswith(f"pm-reset-held-{os.getpid()}-"))
self.assertFalse(lock_is_free(folder / "owner.lock"))
seeder.close()
self.assertEqual(list(memory.iterdir()), [])
@HAS_FLOCK
def test_a_lock_that_cannot_be_taken_puts_the_scratch_on_disk(self):
memory = self.root / "memory"
memory.mkdir()
seeder = ScratchSeeder(self.api, self.root / "fallback", memory_root=memory)
self.addCleanup(seeder.close)
refuse = mock.patch.object(
fast_reset.fcntl, "flock", side_effect=OSError("no locks here")
)
with refuse:
seeder.run(reseed)
self.assertEqual(list(memory.iterdir()), [])
self.assertTrue((self.root / "fallback" / "seed.db").exists())
def test_close_releases_the_scratch_files(self):
self.seeder.run(reseed)
self.seeder.close()
assert_closed(self, self.root / "scratch")
self.seeder.close() # idempotent
# A process that takes the owner lock of a folder, says so, and waits.
HOLDER = """
import fcntl, os, sys, time
fd = os.open(os.path.join(sys.argv[1], "owner.lock"), os.O_RDWR | os.O_CREAT)
fcntl.flock(fd, fcntl.LOCK_EX)
print("held", flush=True)
time.sleep(600)
"""
def lock_is_free(path: Path) -> bool:
fd = os.open(path, os.O_RDWR)
try:
fast_reset.fcntl.flock(fd, fast_reset.fcntl.LOCK_EX | fast_reset.fcntl.LOCK_NB)
except BlockingIOError:
return False
finally:
os.close(fd)
return True
@HAS_FLOCK
class TestOrphanedScratch(unittest.TestCase):
"""Only folders whose owner lock is free are orphans (#419)."""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.memory = Path(self.tmp.name)
def folder(self, name: str) -> Path:
folder = self.memory / name
folder.mkdir()
(folder / "seed.db").write_bytes(b"x")
return folder
def hold(self, folder: Path) -> subprocess.Popen:
holder = subprocess.Popen(
[sys.executable, "-c", HOLDER, str(folder)],
stdout=subprocess.PIPE,
text=True,
)
self.addCleanup(holder.wait)
self.addCleanup(holder.stdout.close)
self.addCleanup(holder.kill)
self.assertEqual(holder.stdout.readline().strip(), "held")
return holder
def test_a_folder_whose_owner_was_killed_is_removed(self):
folder = self.folder("pm-reset-held-123-abc")
holder = self.hold(folder)
holder.kill()
holder.wait()
fast_reset.remove_orphaned_scratch(self.memory)
self.assertFalse(folder.exists())
def test_a_held_folder_is_kept_whatever_pid_its_name_carries(self):
# Under --ipc=host the owner's pid is from another PID namespace: it
# does not exist here, but its lock does.
dead = subprocess.Popen([sys.executable, "-c", "pass"])
dead.wait()
folder = self.folder(f"pm-reset-held-{dead.pid}-abc")
self.hold(folder)
fast_reset.remove_orphaned_scratch(self.memory)
self.assertTrue(folder.exists())
self.assertTrue((folder / "seed.db").exists())
def test_folders_without_an_owner_lock_are_kept(self):
dead = subprocess.Popen([sys.executable, "-c", "pass"])
dead.wait()
legacy = self.folder(f"pm-reset-{dead.pid}-abc")
staging = self.folder(f"pm-reset-new-{dead.pid}-def")
unlocked = self.folder(f"pm-reset-held-{dead.pid}-ghi")
fast_reset.remove_orphaned_scratch(self.memory)
for folder in (legacy, staging, unlocked):
self.assertTrue(folder.exists(), folder.name)
def test_a_folder_held_by_another_seeder_in_this_process_is_kept(self):
api = types.SimpleNamespace(db=PromptDatabase(str(self.memory / "live.db")))
self.addCleanup(api.db.close_all)
root = self.memory / "shm"
root.mkdir()
other = ScratchSeeder(api, self.memory / "fallback", memory_root=root)
self.addCleanup(other.close)
other.run(reseed)
(folder,) = root.iterdir()
fast_reset.remove_orphaned_scratch(root)
self.assertTrue(folder.exists())
if __name__ == "__main__":
unittest.main()