Files
ComfyAssets-ComfyUI_PromptM…/tests/test_model_gate.py
T
Vito Sansevero 489d449ec8 feat(autotag): scored suggestions with a configurable count behind a shared model gate
- py/model_gate.py: one process-wide, non-blocking gate for loading,
  swapping, running, downloading and unloading a tagging model. Worker
  calls run shielded; a release requested while one still runs takes
  effect only when it exits (client disconnect keeps the gate).
- Legacy /autotag/single, /start, /download/{type} and /unload hold the
  gate and answer 409 {code: "model-busy"} while another operation holds
  it; their bodies are otherwise unchanged. /autotag/models gains
  suggestion_limit.
- AutoTagService.generate_tag_suggestions: WD14 (name, probability) best
  first, GGUF/HF (name, None) in output order, first `limit` distinct
  case-insensitively; generate_tags keeps its return; the image file is
  closed before inference returns.
- POST /prompt_manager/image-annotations/suggest runs one engine on the
  pinned capture, with strict per-engine options, never downloads
  (409 model-unavailable) and never writes.
- config.json autotag.defaults.suggestion_limit, integer 1..100, default
  10, validated on load.
- Capture probe decides Generate from the image header alone, so prompt
  metadata over the inspection budgets no longer blocks it.
2026-10-22 09:12:01 -07:00

177 lines
6.6 KiB
Python

"""The shared, non-blocking model gate (#249 B2).
One owner at a time may load, swap, run, download or unload a tagging model.
A release requested while gated work still runs in a worker thread takes
effect only when that work really exits, so a client disconnect never lets a
second owner swap the model under a running inference.
"""
import asyncio
import os
import sys
import threading
import unittest
import unittest.mock
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
try: # guarded so a missing module fails assertions, not test loading
from py import model_gate
except ImportError:
model_gate = None
WAIT = 30.0 # ceiling for cross-thread handoffs, never a short clock
class GateTestCase(unittest.TestCase):
def setUp(self):
self.assertIsNotNone(model_gate, "py.model_gate is not implemented")
self.gate = model_gate.ModelGate()
class TestGate(GateTestCase):
def test_gate_is_nonblocking_and_single_owner(self):
self.assertIsNone(self.gate.holder())
self.assertTrue(self.gate.try_acquire("legacy-batch"))
self.assertEqual(self.gate.holder(), "legacy-batch")
# A second owner is refused at once, never queued.
self.assertFalse(self.gate.try_acquire("image-annotations"))
self.assertFalse(self.gate.try_acquire("legacy-batch"))
self.assertEqual(self.gate.holder(), "legacy-batch")
# Only the holder can release.
self.gate.release("image-annotations")
self.assertEqual(self.gate.holder(), "legacy-batch")
self.gate.release("legacy-batch")
self.assertIsNone(self.gate.holder())
self.gate.release("legacy-batch") # idempotent
self.assertTrue(self.gate.try_acquire("image-annotations"))
self.assertEqual(self.gate.holder(), "image-annotations")
def test_release_waits_for_running_work(self):
self.assertTrue(self.gate.try_acquire("owner"))
self.gate.begin("owner")
self.gate.release("owner")
self.assertEqual(self.gate.holder(), "owner")
self.assertFalse(self.gate.try_acquire("other"))
# No new work is admitted once the owner let go.
with self.assertRaises(model_gate.GateNotHeld):
self.gate.begin("owner")
self.gate.end("owner")
self.assertIsNone(self.gate.holder())
def test_begin_requires_the_holder(self):
with self.assertRaises(model_gate.GateNotHeld):
self.gate.begin("nobody")
self.assertTrue(self.gate.try_acquire("owner"))
with self.assertRaises(model_gate.GateNotHeld):
self.gate.begin("other")
self.gate.end("other") # a stray end never disturbs the holder
self.assertEqual(self.gate.holder(), "owner")
def test_module_gate_is_one_process_wide_instance(self):
self.assertIsNone(model_gate.holder())
self.assertTrue(model_gate.try_acquire("a"))
try:
self.assertFalse(model_gate.try_acquire("b"))
self.assertEqual(model_gate.holder(), "a")
finally:
model_gate.release("a")
self.assertIsNone(model_gate.holder())
def test_new_owner_names_are_unique(self):
first = model_gate.new_owner("legacy-single")
second = model_gate.new_owner("legacy-single")
self.assertNotEqual(first, second)
self.assertTrue(first.startswith("legacy-single:"))
class TestGatedExecutor(GateTestCase):
def test_cancelled_waiter_keeps_the_gate_until_the_thread_exits(self):
started = threading.Event()
proceed = threading.Event()
finished = threading.Event()
def blocking_work():
started.set()
proceed.wait(WAIT)
finished.set()
return "done"
async def scenario():
self.assertTrue(self.gate.try_acquire("native"))
task = asyncio.ensure_future(self.gate.run("native", blocking_work))
try:
await asyncio.get_running_loop().run_in_executor(
None, started.wait, WAIT
)
task.cancel()
with self.assertRaises(asyncio.CancelledError):
await task
finally:
self.gate.release("native")
# The waiter is gone but the inference still runs: still held.
self.assertEqual(self.gate.holder(), "native")
self.assertFalse(self.gate.try_acquire("legacy-unload"))
proceed.set()
await asyncio.get_running_loop().run_in_executor(None, finished.wait, WAIT)
for _ in range(1000):
if self.gate.holder() is None:
break
await asyncio.sleep(0.01)
self.assertIsNone(self.gate.holder())
asyncio.run(scenario())
def test_run_returns_the_result_and_propagates_errors(self):
async def scenario():
self.assertTrue(self.gate.try_acquire("owner"))
try:
self.assertEqual(await self.gate.run("owner", lambda x: x * 2, 21), 42)
def boom():
raise ValueError("bad")
with self.assertRaises(ValueError):
await self.gate.run("owner", boom)
self.assertEqual(self.gate.holder(), "owner")
finally:
self.gate.release("owner")
self.assertIsNone(self.gate.holder())
with self.assertRaises(model_gate.GateNotHeld):
await self.gate.run("owner", lambda: None)
asyncio.run(scenario())
def test_work_that_never_starts_gives_its_admission_back(self):
async def scenario():
loop = asyncio.get_running_loop()
self.assertTrue(self.gate.try_acquire("owner"))
def refused(executor, func):
raise RuntimeError("executor shut down")
with unittest.mock.patch.object(loop, "run_in_executor", refused):
with self.assertRaises(RuntimeError):
await self.gate.run("owner", lambda: None)
def cancelled(executor, func):
future = loop.create_future()
future.cancel() # a loop shutdown drops the queued call
return future
with unittest.mock.patch.object(loop, "run_in_executor", cancelled):
with self.assertRaises(asyncio.CancelledError):
await self.gate.run("owner", lambda: None)
await asyncio.sleep(0) # let the done callback run
self.gate.release("owner")
self.assertIsNone(self.gate.holder())
asyncio.run(scenario())
if __name__ == "__main__":
unittest.main()