Files
ComfyAssets-ComfyUI_PromptM…/tests/test_image_monitor_singleton.py
T
Vito c7ba481d3c perf(images): refresh the listing when the monitor sees a new output (#348, PR 2) (#353)
* perf(images): refresh the listing when the monitor sees a new output (#348, PR 2)

The image monitor now reports every settled output (linked or not, also
when linking raised) with its resolved path through set_output_listener.
The API's notify_output_settled hops from the monitor's worker thread onto
the server's loop (call_soon_threadsafe) and does nothing before the loop is
known or after cleanup. mark_root_dirty matches the path against each
walk's resolved_root (normcase(realpath), computed in the executor by
walk_root, now its fourth value) and marks the root dirty.

A dirty root gets one background walk, no sooner than
rewalk_interval = min(15, max(5, 5 x last walk's duration)) after the
previous walk started (last_started_at / last_duration are recorded on
every walk). _schedule_walk is shared with PR 1's delayed stale refresh:
one deferred walk per root, never armed while a walk runs (the walk's end
re-checks dirty: the trailing walk), at least 0.5 s away, the earlier
deadline wins, and every walk start cancels it. A failed walk clears dirty.
Rescan semantics are unchanged.

* fix(images): keep removed roots and cancelled walks from walking again (#348, PR 2)

Review findings on the monitor-triggered walks:

- reset_gallery_roots_cache now retires every listing record: its pending
  timer is cancelled, dirty is cleared, and no mark, timer or trailing walk
  starts a walk of it until a reader or the warm-up asks for that root
  again. Before, a timer or trailing walk pending when the roots changed
  stored a listing for the removed root, and the monitor (which watches the
  old roots until restart) kept it walking on every new output.
- A walk task cancelled outside cleanup_gallery_listings no longer starts a
  trailing walk while the loop shuts down.
- root_contains ignores a Windows extended-length prefix (\\?\ and
  \\?\UNC\) on either side, which realpath can keep on a file past MAX_PATH.
- The REWALK comment states the ~20% GIL share only for walks up to 3 s.

* test(images): prove a cancelled walk starts no trailing walk (#348, PR 2)

The previous assertions held even with the trailing walk, because that
walk finished before they ran; assert the walk number and the scan count.

* perf(images): add a settled output to the listing without a walk (#348, PR 2)

Live test: each landing in a 20,000-file folder cost a full walk (~0.4 s of
GIL) plus a full workspace rebuild, so Images reads during a run took up to
2.1 s. The monitor's report now patches the stored listing instead.

- output_settled (the monitor hop's target) slices the file's part below
  the root in its own case (relative_part; Windows compares folded), and
  _run_adds places one root's pending files in one executor batch:
  _settled_entry applies the walk's filters (suffix, thumbnails, depth,
  symlink by lstat; macOS on-disk spelling), insert_entry puts the
  (Path, mtime) entry where the newest-first sort does, add_folder copies
  the folder sets. Stored only when no walk started and no drop happened
  meanwhile; walked_at is untouched, so the 30 s refresh still walks.
- Anything inexact marks the root dirty and walks as before: a running
  walk, an unsliceable path, a failed lstat, a tie in mtime, the cap, an
  unexpected error. Rescan, the refresh, the warm-up and invalidation stay
  full walks; a walk start forgets the pending adds.
- The workspace caches follow a patched listing by entry identity
  (listing_delta): only added values are built, the newest order places
  them by bisection, the favorites lookup is copied and extended.

Synthetic 20,000 files: per landing 252 ms (walk + full build) -> 18 ms
(3 ms add + 15 ms warm build).

* fix(images): review fixes for adds without a walk (#348, PR 2)

- _newest_step steps the cached newest order only when the per-file cache
  still holds exactly the values this call built. A stale page read racing
  a newer warm build (both run in executor threads) stored an order
  labelled with older values than it held, and the next read inserted the
  new file a second time. It also sorts in full when a roots reset dropped
  the per-file values meanwhile.
- The stored spelling is checked on Windows too (nt._getfinalpathname:
  realpath keeps the given case when it cannot open the file), and before
  the name filters, which compare the walk's stored spelling. macOS opens
  non-blocking, so a FIFO named like an image never holds the executor.
- An output in a folder the walk cannot list (no read permission) is not
  added, as the walk skips it.
- Windows-portable unit tests: POSIX paths in the insert tests, and a
  str.lower fold for the length guard (Windows' normcase keeps lengths).

* test(images): reproduce the newest-order race and fix a Windows clock flake (#348, PR 2)

- The race test now has the newer build step from the values the cached
  order was built from, which is the interleaving that inserted a file
  twice; it fails without the parts check in _newest_step.
- test_a_failed_walk_records_its_timing_and_clears_dirty compared two
  monotonic readings that Windows' ~15.6 ms clock can make equal; the
  previous start is moved back instead.
- TESTING.md: os.access ignores ACLs on Windows.
2026-10-26 16:14:50 -07:00

596 lines
23 KiB
Python

"""
Tests for image monitor singleton lifecycle.
Verifies that:
- The singleton image monitor survives node garbage collection
- Node __del__ / cleanup_gallery_system does NOT stop the shared monitor
- Multiple node instances share the same monitor
- The monitor observer stays alive across node lifecycles
"""
import os
import sys
import tempfile
import threading
import time
import types
import unittest
from unittest.mock import MagicMock, patch
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# Mock ComfyUI server before importing anything that touches config. Only the
# first test module to run installs the stub; nothing else in sys.modules is
# replaced, so later modules (test_comfyui_integration) see the real package.
_mock_server = MagicMock()
_mock_server.PromptServer.instance.routes = MagicMock()
sys.modules.setdefault("server", _mock_server)
from utils.image_monitor import get_image_monitor
import utils.image_monitor as im_mod
class TestMonitorSingletonLifecycle(unittest.TestCase):
"""The singleton monitor must survive node instance garbage collection."""
def setUp(self):
# Reset singleton for each test
im_mod._monitor_instance = None
def tearDown(self):
im_mod._monitor_instance = None
def test_get_image_monitor_returns_singleton(self):
"""Multiple calls should return the exact same instance."""
db = MagicMock()
tracker = MagicMock()
m1 = get_image_monitor(db, tracker)
m2 = get_image_monitor(db, tracker)
self.assertIs(m1, m2)
def test_singleton_survives_across_different_callers(self):
"""Different db/tracker args on subsequent calls still return the same
instance."""
db1, tracker1 = MagicMock(), MagicMock()
db2, tracker2 = MagicMock(), MagicMock()
m1 = get_image_monitor(db1, tracker1)
m2 = get_image_monitor(db2, tracker2)
self.assertIs(m1, m2)
def test_cleanup_does_not_stop_monitor(self):
"""PromptManagerBase.cleanup_gallery_system must NOT stop the monitor."""
from prompt_manager_base import PromptManagerBase
with patch.object(PromptManagerBase, "__init__", lambda self, **kw: None):
node = PromptManagerBase()
node.logger = MagicMock()
mock_monitor = MagicMock()
node.image_monitor = mock_monitor
node.cleanup_gallery_system()
mock_monitor.stop_monitoring.assert_not_called()
def test_del_does_not_stop_monitor(self):
"""Node __del__ must NOT stop the singleton monitor."""
from prompt_manager_base import PromptManagerBase
with patch.object(PromptManagerBase, "__init__", lambda self, **kw: None):
node = PromptManagerBase()
node.logger = MagicMock()
mock_monitor = MagicMock()
node.image_monitor = mock_monitor
# Simulate garbage collection
del node
mock_monitor.stop_monitoring.assert_not_called()
def test_observer_stays_alive_after_node_cleanup(self):
"""A running observer must remain alive after node cleanup."""
db = MagicMock()
tracker = MagicMock()
monitor = get_image_monitor(db, tracker)
# Simulate a running observer
mock_observer = MagicMock()
mock_observer.is_alive.return_value = True
monitor.observer = mock_observer
monitor.handler = MagicMock()
monitor.monitored_directories = ["/fake/output"]
from prompt_manager_base import PromptManagerBase
with patch.object(PromptManagerBase, "__init__", lambda self, **kw: None):
node = PromptManagerBase()
node.logger = MagicMock()
node.image_monitor = monitor
node.cleanup_gallery_system()
# Observer must still be alive
self.assertIsNotNone(monitor.observer)
self.assertTrue(monitor.observer.is_alive())
self.assertEqual(monitor.monitored_directories, ["/fake/output"])
def test_monitor_start_not_called_when_already_running(self):
"""start_monitoring should be a no-op if observer is already active."""
db = MagicMock()
tracker = MagicMock()
monitor = get_image_monitor(db, tracker)
# Set up as if already running
mock_observer = MagicMock()
monitor.observer = mock_observer
monitor.start_monitoring()
# Should not create a new observer
self.assertIs(monitor.observer, mock_observer)
class FakeObserver:
"""Records what the monitor does with the watchdog observer."""
def __init__(self):
self.scheduled = []
self.started = False
self.stopped = False
self.joined = False
def schedule(self, handler, path, recursive=False):
self.scheduled.append((handler, path, recursive))
def start(self):
self.started = True
def stop(self):
self.stopped = True
def join(self, timeout=None):
self.joined = True
def is_alive(self):
return self.started and not self.stopped
class TestStartStop(unittest.TestCase):
"""start_monitoring validates directories before it creates an observer."""
def setUp(self):
im_mod._monitor_instance = None
self.monitor = im_mod.ImageMonitor(MagicMock(), MagicMock())
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.missing = os.path.join(self.tmp.name, "does-not-exist")
def tearDown(self):
im_mod._monitor_instance = None
def test_only_nonexistent_directories_leaves_monitoring_off(self):
with patch.object(im_mod, "Observer") as observer_cls:
self.monitor.start_monitoring([self.missing])
observer_cls.assert_not_called()
self.assertFalse(self.monitor.is_monitoring)
self.assertIsNone(self.monitor.observer)
self.assertEqual(self.monitor.monitored_directories, [])
self.monitor.stop_monitoring() # must not raise
def test_stop_without_start_does_not_raise(self):
self.monitor.stop_monitoring()
self.assertFalse(self.monitor.is_monitoring)
def test_start_twice_is_a_noop(self):
fake = FakeObserver()
with patch.object(im_mod, "Observer", return_value=fake) as observer_cls:
self.monitor.start_monitoring([self.tmp.name])
self.monitor.start_monitoring([self.tmp.name])
self.assertEqual(observer_cls.call_count, 1)
self.assertEqual(len(fake.scheduled), 1)
self.assertTrue(self.monitor.is_monitoring)
def test_stop_joins_the_observer_and_clears_state(self):
fake = FakeObserver()
with patch.object(im_mod, "Observer", return_value=fake):
self.monitor.start_monitoring([self.tmp.name, self.missing])
self.assertTrue(fake.started)
self.assertEqual([p for _, p, _ in fake.scheduled], [self.tmp.name])
self.assertEqual(self.monitor.monitored_directories, [self.tmp.name])
self.assertTrue(self.monitor.get_status()["observer_alive"])
self.monitor.stop_monitoring()
self.assertTrue(fake.stopped)
self.assertTrue(fake.joined)
self.assertFalse(self.monitor.is_monitoring)
self.assertIsNone(self.monitor.handler)
self.assertEqual(self.monitor.monitored_directories, [])
self.assertFalse(self.monitor.get_status()["running"])
def test_stop_shuts_the_handler_worker_down(self):
fake = FakeObserver()
with patch.object(im_mod, "Observer", return_value=fake):
self.monitor.start_monitoring([self.tmp.name])
handler = self.monitor.handler
handler.processing_delay = 10
image = os.path.join(self.tmp.name, "w.png")
with open(image, "wb") as f:
f.write(b"x" * 16)
handler.schedule(image)
worker = handler._worker
self.assertTrue(worker.is_alive())
self.monitor.stop_monitoring()
worker.join(timeout=2)
self.assertFalse(worker.is_alive())
self.assertEqual(handler.pending_count(), 0)
# A restart gets a fresh handler and observer
fake2 = FakeObserver()
with patch.object(im_mod, "Observer", return_value=fake2):
self.monitor.start_monitoring([self.tmp.name])
self.assertTrue(self.monitor.is_monitoring)
self.assertIsNot(self.monitor.handler, handler)
self.monitor.stop_monitoring()
def test_observer_failing_to_start_leaves_monitoring_off(self):
fake = FakeObserver()
fake.start = MagicMock(side_effect=OSError("inotify limit reached"))
with patch.object(im_mod, "Observer", return_value=fake):
self.monitor.start_monitoring([self.tmp.name])
self.assertFalse(self.monitor.is_monitoring)
self.monitor.stop_monitoring()
def test_disabled_in_config_leaves_monitoring_off(self):
config = types.SimpleNamespace(
MONITORING_ENABLED=False, MONITORING_DIRECTORIES=[]
)
with patch.object(im_mod, "Observer") as observer_cls:
with patch.object(
im_mod.ImageMonitor, "_gallery_config", return_value=config
):
self.monitor.start_monitoring([self.tmp.name])
observer_cls.assert_not_called()
self.assertFalse(self.monitor.is_monitoring)
def test_directories_failing_validation_are_not_watched(self):
allowed = os.path.join(self.tmp.name, "allowed")
os.mkdir(allowed)
config = types.SimpleNamespace(
MONITORING_ENABLED=True,
MONITORING_DIRECTORIES=[self.tmp.name, allowed],
validate_gallery_root=lambda p: (p == allowed, "outside ComfyUI"),
)
fake = FakeObserver()
with patch.object(im_mod, "Observer", return_value=fake):
with patch.object(
im_mod.ImageMonitor, "_gallery_config", return_value=config
):
self.monitor.start_monitoring()
self.assertEqual([p for _, p, _ in fake.scheduled], [allowed])
self.assertEqual(self.monitor.monitored_directories, [allowed])
self.monitor.stop_monitoring()
def test_explicit_directories_are_validated_too(self):
config = types.SimpleNamespace(
MONITORING_ENABLED=True,
MONITORING_DIRECTORIES=[],
validate_gallery_root=lambda p: (False, "outside ComfyUI"),
)
with patch.object(im_mod, "Observer") as observer_cls:
with patch.object(
im_mod.ImageMonitor, "_gallery_config", return_value=config
):
self.monitor.start_monitoring([self.tmp.name])
observer_cls.assert_not_called()
self.assertFalse(self.monitor.is_monitoring)
def test_configured_directories_are_used_when_none_are_given(self):
config = types.SimpleNamespace(
MONITORING_ENABLED=True, MONITORING_DIRECTORIES=[self.tmp.name]
)
fake = FakeObserver()
with patch.object(im_mod, "Observer", return_value=fake):
with patch.object(
im_mod.ImageMonitor, "_gallery_config", return_value=config
):
self.monitor.start_monitoring()
self.assertEqual(self.monitor.monitored_directories, [self.tmp.name])
self.monitor.stop_monitoring()
class FakeEvent:
def __init__(self, path, is_directory=False):
self.src_path = path
self.is_directory = is_directory
def wait_for(predicate, timeout=5.0):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if predicate():
return True
time.sleep(0.01)
return predicate()
class TestHandlerScheduling(unittest.TestCase):
"""New images are processed once each, in order, after the file settles."""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.tracker = MagicMock()
self.tracker.keyed_snapshot.return_value = {"key": ({"id": 7}, "run", 1)}
self.handler = im_mod.ImageGenerationHandler(MagicMock(), self.tracker)
self.handler.processing_delay = 0
self.handler.settle_interval = 0.01
self.processed = []
self.handler.process_new_image = lambda path, prompt_snapshot=None: (
self.processed.append((path, prompt_snapshot))
)
def _image(self, name, content=b"x" * 16):
path = os.path.join(self.tmp.name, name)
with open(path, "wb") as f:
f.write(content)
return path
def test_same_path_is_scheduled_once(self):
path = self._image("a.png")
self.assertTrue(self.handler.schedule(path, {"id": 1}))
self.assertFalse(self.handler.schedule(path, {"id": 2}))
self.assertTrue(wait_for(lambda: self.handler.pending_count() == 0))
self.assertEqual(self.processed, [(path, {"id": 1})])
def test_on_created_snapshots_the_prompt_and_skips_non_images(self):
path = self._image("b.png")
self.handler.on_created(FakeEvent(path))
self.handler.on_created(FakeEvent(os.path.join(self.tmp.name, "c.txt")))
self.handler.on_created(FakeEvent(self.tmp.name, is_directory=True))
self.assertTrue(wait_for(lambda: self.handler.pending_count() == 0))
self.assertEqual(self.processed, [(path, {"key": ({"id": 7}, "run", 1)})])
def test_a_failed_snapshot_still_schedules_the_image(self):
self.tracker.keyed_snapshot.side_effect = RuntimeError("tracker gone")
path = self._image("d.png")
self.handler.on_created(FakeEvent(path))
self.assertTrue(wait_for(lambda: self.handler.pending_count() == 0))
self.assertEqual(self.processed, [(path, None)])
def test_images_are_processed_in_creation_order(self):
paths = [self._image(f"{i}.png") for i in range(5)]
for path in paths:
self.handler.schedule(path)
self.assertTrue(wait_for(lambda: self.handler.pending_count() == 0))
self.assertEqual([p for p, _ in self.processed], paths)
def test_pending_set_is_bounded(self):
self.handler.processing_delay = 10 # keep the worker busy
first = self._image("first.png")
self.handler.schedule(first)
with patch.object(im_mod, "MAX_PENDING_IMAGES", 2):
self.assertTrue(self.handler.schedule(self._image("second.png")))
self.assertFalse(self.handler.schedule(self._image("third.png")))
self.assertEqual(self.handler.pending_count(), 2)
def test_file_that_never_settles_is_skipped_and_cleared(self):
path = self._image("d.png")
sizes = iter(range(1, 100))
with patch.object(im_mod.os.path, "getsize", side_effect=lambda p: next(sizes)):
self.handler._process_pending(path)
self.assertEqual(self.processed, [])
self.assertEqual(self.handler.pending_count(), 0)
def test_vanished_file_is_skipped(self):
path = os.path.join(self.tmp.name, "gone.png")
self.assertFalse(self.handler.wait_until_settled(path))
self.handler._process_pending(path)
self.assertEqual(self.processed, [])
def test_settled_file_is_detected_after_two_stable_reads(self):
path = self._image("e.png")
self.assertTrue(self.handler.wait_until_settled(path))
empty = self._image("empty.png", content=b"")
self.assertFalse(self.handler.wait_until_settled(empty))
def test_shutdown_stops_the_worker_and_clears_pending(self):
self.handler.processing_delay = 10 # keep the first image waiting
self.handler.schedule(self._image("s1.png"))
self.handler.schedule(self._image("s2.png"))
worker = self.handler._worker
self.assertTrue(worker.is_alive())
self.handler.shutdown()
worker.join(timeout=2)
self.assertFalse(worker.is_alive())
self.assertEqual(self.handler.pending_count(), 0)
def test_shutdown_is_idempotent_and_safe_before_start(self):
fresh = im_mod.ImageGenerationHandler(MagicMock(), self.tracker)
fresh.shutdown()
fresh.shutdown()
self.handler.schedule(self._image("s3.png"))
self.assertTrue(wait_for(lambda: self.handler.pending_count() == 0))
self.handler.shutdown()
self.handler.shutdown()
def test_scheduling_after_shutdown_restarts_the_worker(self):
self.handler.schedule(self._image("r1.png"))
self.assertTrue(wait_for(lambda: self.handler.pending_count() == 0))
old_worker = self.handler._worker
self.handler.shutdown()
old_worker.join(timeout=2)
self.assertFalse(old_worker.is_alive())
path = self._image("r2.png")
self.assertTrue(self.handler.schedule(path))
self.assertTrue(wait_for(lambda: self.handler.pending_count() == 0))
self.assertIn(path, [p for p, _ in self.processed])
self.assertIsNot(self.handler._worker, old_worker)
self.handler.shutdown()
def test_processing_errors_do_not_kill_the_worker(self):
def explode(path, prompt_snapshot=None):
self.processed.append(path)
raise RuntimeError("boom")
self.handler.process_new_image = explode
first, second = self._image("f1.png"), self._image("f2.png")
self.handler.schedule(first)
self.handler.schedule(second)
self.assertTrue(wait_for(lambda: self.handler.pending_count() == 0))
self.assertEqual(self.processed, [first, second])
class TestOutputListener(unittest.TestCase):
"""Every settled output is reported to the Images listing (#348, PR 2)."""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.handler = im_mod.ImageGenerationHandler(MagicMock(), MagicMock())
self.addCleanup(self.handler.shutdown)
self.handler.processing_delay = 0
self.handler.settle_interval = 0.01
self.handler.process_new_image = lambda path, prompt_snapshot=None: None
self.reported = []
self.handler.output_listener = self.reported.append
def _image(self, name, content=b"x" * 16):
path = os.path.join(self.tmp.name, name)
with open(path, "wb") as f:
f.write(content)
return path
def test_a_settled_output_is_reported_once_with_its_resolved_path(self):
path = self._image("a.png")
self.handler._process_pending(path)
self.assertEqual(self.reported, [os.path.realpath(path)])
def test_a_symlinked_spelling_is_reported_resolved(self):
real_dir = os.path.join(self.tmp.name, "real")
os.makedirs(real_dir)
link_dir = os.path.join(self.tmp.name, "link")
try:
os.symlink(real_dir, link_dir, target_is_directory=True)
except (OSError, NotImplementedError) as error:
self.skipTest(f"symlinks unavailable: {error}")
with open(os.path.join(real_dir, "b.png"), "wb") as f:
f.write(b"x" * 16)
self.handler._process_pending(os.path.join(link_dir, "b.png"))
self.assertEqual(
self.reported, [os.path.realpath(os.path.join(real_dir, "b.png"))]
)
def test_it_is_reported_even_when_linking_raises(self):
def explode(path, prompt_snapshot=None):
raise RuntimeError("boom")
self.handler.process_new_image = explode
path = self._image("c.png")
with self.assertRaises(RuntimeError):
self.handler._process_pending(path)
self.assertEqual(self.reported, [os.path.realpath(path)])
self.assertEqual(self.handler.pending_count(), 0)
def test_an_output_that_never_settles_is_not_reported(self):
self.handler.settle_attempts = 3
self.handler._process_pending(os.path.join(self.tmp.name, "gone.png"))
empty = self._image("empty.png", content=b"")
self.handler._process_pending(empty)
self.assertEqual(self.reported, [])
def test_a_video_is_reported_when_its_extension_is_monitored(self):
self.handler.supported_extensions = (".png", ".mp4")
path = self._image("clip.mp4")
self.handler.on_created(FakeEvent(path))
self.assertTrue(wait_for(lambda: self.reported != []))
self.assertEqual(self.reported, [os.path.realpath(path)])
def test_a_failing_listener_is_logged_and_the_worker_carries_on(self):
def broken(_path):
raise RuntimeError("listing gone")
self.handler.output_listener = broken
first = self._image("d.png")
with self.assertLogs("prompt_manager.image_monitor", "WARNING") as logs:
self.handler._process_pending(first) # no exception
self.assertIn("Images listing", "\n".join(logs.output))
self.handler.output_listener = self.reported.append
second = self._image("e.png")
self.handler.schedule(second)
self.assertTrue(wait_for(lambda: self.reported != []))
self.assertEqual(self.reported, [os.path.realpath(second)])
def test_no_listener_reports_nothing(self):
self.handler.output_listener = None
self.handler._process_pending(self._image("f.png")) # no exception
self.assertEqual(self.reported, [])
def test_the_monitor_wires_running_and_later_handlers(self):
monitor = im_mod.ImageMonitor(MagicMock(), MagicMock())
listener = self.reported.append
monitor.set_output_listener(listener)
with (
patch.object(im_mod, "Observer", FakeObserver),
patch.object(im_mod.ImageMonitor, "_gallery_config", return_value=None),
):
monitor.start_monitoring([self.tmp.name])
try:
self.assertIs(monitor.handler.output_listener, listener)
monitor.set_output_listener(None)
self.assertIsNone(monitor.output_listener)
self.assertIsNone(monitor.handler.output_listener)
finally:
monitor.stop_monitoring()
class TestModuleIsolation(unittest.TestCase):
"""This module must not leave mocks behind for later test modules."""
def test_comfyui_integration_is_not_replaced_by_a_mock(self):
module = sys.modules.get("utils.comfyui_integration")
self.assertFalse(
isinstance(module, MagicMock),
"utils.comfyui_integration was replaced by a MagicMock in sys.modules",
)
class TestMonitorThreadSafety(unittest.TestCase):
"""Singleton creation must be thread-safe."""
def setUp(self):
im_mod._monitor_instance = None
def tearDown(self):
im_mod._monitor_instance = None
def test_concurrent_get_image_monitor_returns_same_instance(self):
"""Multiple threads calling get_image_monitor must get the same instance."""
results = []
barrier = threading.Barrier(5)
def get_monitor():
barrier.wait()
m = get_image_monitor(MagicMock(), MagicMock())
results.append(id(m))
threads = [threading.Thread(target=get_monitor) for _ in range(5)]
for t in threads:
t.start()
for t in threads:
t.join()
self.assertEqual(len(set(results)), 1, "All threads must get the same instance")
if __name__ == "__main__":
unittest.main()