Files
ComfyAssets-ComfyUI_PromptM…/tests/test_utils_logging_config.py
T
Vito ac23f22a37 fix(logging): close retired owned handlers (#225)
* fix(logging): close replaced owned handlers (#218)

* fix(logging): keep retired file handlers closed (#218)
2026-10-18 10:01:19 -07:00

489 lines
20 KiB
Python

"""PromptManagerLogger: memory buffer, file rotation, level changes, truncation."""
import logging
import os
import pathlib
import sys
import tempfile
import threading
import unittest
import unittest.mock as mock
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from utils import logging_config
from utils.logging_config import (
MemoryBufferHandler,
PromptManagerLogger,
get_logger,
get_logger_manager,
)
class IsolatedLoggerTestCase(unittest.TestCase):
"""A logger manager whose log directory is a temporary folder."""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
package = os.path.join(self.tmp.name, "pkg")
os.makedirs(os.path.join(package, "utils"))
self.log_dir = os.path.join(package, "logs")
fake_file = os.path.join(package, "utils", "logging_config.py")
with mock.patch.object(logging_config, "__file__", fake_file):
self.manager = object.__new__(PromptManagerLogger) # bypass singleton
self.manager.__init__()
self.manager.update_config({"console_logging": False})
# Drop the "Updated logging configuration" entry.
self.manager._log_buffer.clear()
# Other tests leave daemon threads (image monitor, watchdog) logging
# under "prompt_manager.*"; only records from this test's thread may
# reach the buffer or the rotating file (a stray line after a truncate
# would roll the file over and resurrect the backups). update_config
# rebuilds the handlers, so the filter is re-applied after every call.
self._filter_to_this_thread()
original_update = self.manager.update_config
def update_and_refilter(new_config):
original_update(new_config)
self._filter_to_this_thread()
self.manager.update_config = update_and_refilter
self.logger = logging.getLogger("prompt_manager.coverage_test")
self.addCleanup(self._restore_global_logging)
def _filter_to_this_thread(self):
this_thread = threading.get_ident()
for handler in self.manager.logger.handlers:
handler.addFilter(lambda record: record.thread == this_thread)
def _restore_global_logging(self):
for handler in logging.getLogger("prompt_manager").handlers[:]:
handler.close()
get_logger_manager()._setup_loggers()
self.tmp.cleanup()
def _rotate(self, max_file_size=400, backup_count=2, messages=30):
self.manager.update_config(
{"max_file_size": max_file_size, "backup_count": backup_count}
)
for i in range(messages):
self.logger.info("message %03d %s", i, "x" * 80)
class TestSingleton(unittest.TestCase):
def test_manager_and_loggers_are_shared(self):
self.assertIs(PromptManagerLogger(), PromptManagerLogger())
self.assertIs(get_logger_manager(), get_logger_manager())
self.assertIs(
get_logger("prompt_manager.x"), logging.getLogger("prompt_manager.x")
)
self.assertEqual(get_logger().name, "prompt_manager")
def test_init_is_a_noop_on_the_existing_instance(self):
manager = get_logger_manager()
before = manager.config
manager.__init__()
self.assertIs(manager.config, before)
class TestMemoryBuffer(IsolatedLoggerTestCase):
def test_records_are_buffered_most_recent_first(self):
self.logger.info("first")
self.logger.warning("second")
self.logger.error("third")
recent = self.manager.get_recent_logs(limit=2)
self.assertEqual([r["message"] for r in recent], ["third", "second"])
entry = recent[0]
self.assertEqual(entry["level"], "ERROR")
self.assertEqual(entry["logger"], self.logger.name)
self.assertIn("third", entry["formatted"])
for key in ("timestamp", "module", "filename", "lineno", "thread", "process"):
self.assertIn(key, entry)
def test_level_filter(self):
self.logger.info("info")
self.logger.warning("warn")
self.logger.error("err")
self.assertEqual(
[r["message"] for r in self.manager.get_recent_logs(level="warning")],
["err", "warn"],
)
self.assertEqual(len(self.manager.get_recent_logs(level="NOPE")), 3)
def test_buffer_is_capped_at_the_configured_size(self):
for i in range(1005):
self.logger.info("entry %d", i)
stats = self.manager.get_log_stats()
self.assertEqual(stats["buffer_count"], 1000)
self.assertEqual(stats["level_counts"], {"INFO": 1000})
self.assertEqual(stats["current_level"], "INFO")
self.assertEqual(
os.path.normcase(os.path.realpath(stats["log_directory"])),
os.path.normcase(os.path.realpath(self.log_dir)),
)
self.assertEqual(
self.manager.get_recent_logs(limit=1)[0]["message"], "entry 1004"
)
def test_handler_swallows_buffer_errors(self):
manager = mock.Mock()
manager.add_to_buffer.side_effect = RuntimeError("boom")
handler = MemoryBufferHandler(manager)
record = logging.LogRecord("x", logging.INFO, __file__, 1, "msg", (), None)
handler.emit(record) # must not raise
manager.add_to_buffer.assert_called_once()
class TestFilesAndRotation(IsolatedLoggerTestCase):
def test_rotation_creates_backups_and_lists_them_newest_first(self):
self._rotate()
files = self.manager.get_log_files()
names = sorted(f["filename"] for f in files)
self.assertEqual(
names,
["prompt_manager.log", "prompt_manager.log.1", "prompt_manager.log.2"],
)
main = [f for f in files if f["is_main"]]
self.assertEqual(len(main), 1)
self.assertTrue(all(f["size"] > 0 for f in files if not f["is_main"]))
modified = [f["modified"] for f in files]
self.assertEqual(modified, sorted(modified, reverse=True))
self.assertEqual(self.manager.get_log_stats()["log_files_count"], 3)
def test_read_log_file_tail_and_whole(self):
self._rotate()
tail = self.manager.read_log_file("prompt_manager.log.1", lines=1)
self.assertEqual(len(tail), 1)
whole = self.manager.read_log_file("prompt_manager.log.1", lines=0)
self.assertGreater(len(whole), 1)
self.assertTrue(whole[-1].endswith(tail[0]))
def test_read_log_file_rejects_missing_and_outside_paths(self):
with self.assertRaises(FileNotFoundError):
self.manager.read_log_file("missing.log")
outside = os.path.join(self.tmp.name, "pkg", "outside.log")
with open(outside, "w", encoding="utf-8") as f:
f.write("secret\n")
with self.assertRaises(ValueError):
self.manager.read_log_file(os.path.join("..", "outside.log"))
def test_read_log_file_reraises_read_errors(self):
self.logger.info("something")
with mock.patch("builtins.open", side_effect=OSError("disk")):
with self.assertRaises(OSError):
self.manager.read_log_file("prompt_manager.log")
def test_unreadable_files_are_skipped_in_the_listing(self):
self.logger.info("something")
original_stat = pathlib.Path.stat
def stat_failing_for_logs(path, *args, **kwargs):
if path.suffix == ".log":
raise OSError("denied")
return original_stat(path, *args, **kwargs)
with mock.patch.object(pathlib.Path, "stat", autospec=True) as stat:
stat.side_effect = stat_failing_for_logs
self.assertEqual(self.manager.get_log_files(), [])
def test_truncate_does_not_roll_the_log_over_while_deleting_backups(self):
# With a file limit smaller than one record, any log line written
# while the backups are being deleted rolls the main log over and
# resurrects prompt_manager.log.1. The handler's own stream must also
# be reset, or its stale offset reports the old size, rolls over on the
# next record anyway and pads the file with zeros on POSIX.
self._rotate(max_file_size=400, backup_count=2, messages=10)
result = self.manager.truncate_logs()
self.assertEqual(result["errors"], [])
self.logger.info("after truncate")
leftovers = sorted(p.name for p in pathlib.Path(self.log_dir).iterdir())
contents = {
name: pathlib.Path(self.log_dir, name).read_bytes() for name in leftovers
}
self.assertEqual(leftovers, ["prompt_manager.log"], contents)
main_log = pathlib.Path(self.log_dir, "prompt_manager.log").read_bytes()
self.assertNotIn(b"\x00", main_log)
self.assertIn(b"after truncate", main_log)
def test_truncate_clears_main_log_backups_and_buffer(self):
self._rotate()
result = self.manager.truncate_logs()
self.assertEqual(result["errors"], [])
self.assertEqual(
sorted(result["truncated"]),
["prompt_manager.log", "prompt_manager.log.1", "prompt_manager.log.2"],
)
self.assertFalse(
os.path.exists(os.path.join(self.log_dir, "prompt_manager.log.1"))
)
# Only the "Truncated log file" lines written after clearing remain
self.assertEqual(
[
r["message"]
for r in self.manager.get_recent_logs()
if "message 0" in r["message"]
],
[],
)
def test_truncate_reports_files_it_cannot_remove(self):
self._rotate()
with mock.patch.object(pathlib.Path, "unlink", side_effect=OSError("busy")):
result = self.manager.truncate_logs()
self.assertEqual(len(result["errors"]), 2)
self.assertEqual(result["truncated"], ["prompt_manager.log"])
class TestConfiguration(IsolatedLoggerTestCase):
def test_in_flight_record_cannot_reopen_retired_file_handler(self) -> None:
handler = next(
h
for h in self.manager.logger.handlers
if isinstance(h, logging.FileHandler)
)
stream = handler.stream
selected = threading.Event()
resume = threading.Event()
errors = []
message = "in-flight record selected before file logging disabled"
def pause_before_handler_lock(record: logging.LogRecord) -> bool:
if record.getMessage() == message:
selected.set()
if not resume.wait(5):
raise RuntimeError("logging thread was not released")
return True
def log_from_background_thread() -> None:
try:
self.manager.logger.info(message)
except BaseException as error:
errors.append(error)
# Allow this record through the fixture's normal main-thread filter.
handler.filters.clear()
handler.addFilter(pause_before_handler_lock)
worker = threading.Thread(target=log_from_background_thread, daemon=True)
worker.start()
try:
self.assertTrue(selected.wait(5), "logger did not select the file handler")
self.assertFalse(stream.closed)
self.manager.update_config({"file_logging": False})
self.assertNotIn(handler, self.manager.logger.handlers)
self.assertTrue(stream.closed)
self.assertIsNone(handler.stream)
resume.set()
worker.join(5)
self.assertFalse(worker.is_alive(), "logging thread did not finish")
self.assertEqual(errors, [])
self.assertIsNone(handler.stream)
self.assertTrue(stream.closed)
self.assertFalse(self.manager.config["file_logging"])
self.assertFalse(
any(
isinstance(h, logging.FileHandler)
for h in self.manager.logger.handlers
)
)
self.assertNotIn(
message,
pathlib.Path(self.log_dir, "prompt_manager.log").read_text(),
)
finally:
resume.set()
worker.join(5)
handler.close()
def test_rebuild_closes_replaced_file_stream(self) -> None:
old_handler = next(
h
for h in self.manager.logger.handlers
if isinstance(h, logging.FileHandler)
)
stream = old_handler.stream
self.addCleanup(old_handler.close)
self.assertFalse(stream.closed)
self.manager.update_config({"file_logging": False})
self.assertTrue(stream.closed)
self.assertIsNone(old_handler.stream)
self.assertNotIn(old_handler, self.manager.logger.handlers)
log_file = pathlib.Path(self.log_dir, "prompt_manager.log")
renamed = log_file.rename(log_file.with_suffix(".retired"))
renamed.unlink()
self.assertFalse(renamed.exists())
def test_repeated_rebuilds_retire_previous_handlers(self) -> None:
for change in (
{"max_file_size": 4096},
{"backup_count": 3},
{"console_logging": True},
{"console_logging": False},
):
with self.subTest(change=change):
previous = self.manager.logger.handlers[:]
for handler in previous:
self.addCleanup(handler.close)
old_file = next(
h for h in previous if isinstance(h, logging.FileHandler)
)
stream = old_file.stream
self.assertFalse(stream.closed)
console_streams = [
h.stream for h in previous if type(h) is logging.StreamHandler
]
self.manager.update_config(change)
self.assertTrue(stream.closed)
self.assertIsNone(old_file.stream)
self.assertTrue(all(h._closed for h in previous))
self.assertTrue(all(not s.closed for s in console_streams))
self.assertTrue(
all(h not in self.manager.logger.handlers for h in previous)
)
current_files = [
h
for h in self.manager.logger.handlers
if isinstance(h, logging.FileHandler)
]
self.assertEqual(len(current_files), 1)
self.assertFalse(current_files[0].stream.closed)
message = f"usable replacement after {change}"
self.logger.info(message)
self.assertIn(
message,
pathlib.Path(self.log_dir, "prompt_manager.log").read_text(),
)
self.assertEqual(
self.manager.get_recent_logs(limit=1)[0]["message"], message
)
def test_rebuild_does_not_close_external_handler(self) -> None:
external_path = pathlib.Path(self.tmp.name, "external.log")
external_handler = logging.FileHandler(
external_path, mode="w", encoding="utf-8"
)
self.addCleanup(external_handler.close)
other_logger = logging.Logger("external_shared_sink", level=logging.INFO)
other_logger.addHandler(external_handler)
self.addCleanup(other_logger.removeHandler, external_handler)
self.manager.logger.addHandler(external_handler)
with mock.patch.object(
external_handler, "close", wraps=external_handler.close
) as close:
self.manager.update_config({"backup_count": 3})
close.assert_not_called()
self.assertNotIn(external_handler, self.manager.logger.handlers)
self.assertFalse(external_handler.stream.closed)
other_logger.info("external sink still usable")
self.assertIn("external sink still usable", external_path.read_text())
def test_level_only_change_keeps_current_file_handler(self) -> None:
handler = next(
h
for h in self.manager.logger.handlers
if isinstance(h, logging.FileHandler)
)
stream = handler.stream
self.manager.update_config({"level": "DEBUG"})
self.assertIn(handler, self.manager.logger.handlers)
self.assertIs(handler.stream, stream)
self.assertFalse(stream.closed)
self.logger.debug("same sink still usable")
self.assertIn(
"same sink still usable",
pathlib.Path(self.log_dir, "prompt_manager.log").read_text(),
)
def test_replacement_manager_closes_previous_owned_handlers(self) -> None:
previous = self.manager.logger.handlers[:]
for handler in previous:
self.addCleanup(handler.close)
old_file = next(h for h in previous if isinstance(h, logging.FileHandler))
stream = old_file.stream
self.assertFalse(stream.closed)
fake_file = pathlib.Path(self.tmp.name, "pkg", "utils", "logging_config.py")
with mock.patch.object(logging_config, "__file__", str(fake_file)):
replacement = object.__new__(PromptManagerLogger)
replacement.__init__()
self.assertIs(replacement.logger, self.manager.logger)
self.assertTrue(stream.closed)
self.assertIsNone(old_file.stream)
self.assertTrue(all(h._closed for h in previous))
self._filter_to_this_thread()
self.logger.info("replacement manager sink")
self.assertEqual(
replacement.get_recent_logs(limit=1)[0]["message"],
"replacement manager sink",
)
def test_level_change_applies_to_every_prompt_manager_logger(self):
self.manager.update_config({"level": "DEBUG"})
self.assertEqual(logging.getLogger("prompt_manager").level, logging.DEBUG)
self.assertEqual(
logging.getLogger("prompt_manager.database").level, logging.DEBUG
)
self.assertEqual(self.manager.get_config()["level"], "DEBUG")
self.logger.debug("now visible")
self.assertEqual(self.manager.get_recent_logs(limit=1)[0]["level"], "DEBUG")
self.manager.update_config({"level": "bogus"}) # falls back to INFO
self.assertEqual(logging.getLogger("prompt_manager").level, logging.INFO)
def test_get_config_returns_a_copy(self):
config = self.manager.get_config()
config["level"] = "CRITICAL"
self.assertEqual(self.manager.get_config()["level"], "INFO")
def test_handler_flags_rebuild_the_handlers(self):
root = logging.getLogger("prompt_manager")
self.assertFalse(any(type(h) is logging.StreamHandler for h in root.handlers))
self.assertTrue(any(isinstance(h, logging.FileHandler) for h in root.handlers))
self.manager.update_config({"console_logging": True, "file_logging": False})
self.assertTrue(any(type(h) is logging.StreamHandler for h in root.handlers))
self.assertFalse(any(isinstance(h, logging.FileHandler) for h in root.handlers))
self.assertTrue(any(isinstance(h, MemoryBufferHandler) for h in root.handlers))
self.manager.update_config({"console_logging": False})
class TestConsoleNoise(IsolatedLoggerTestCase):
"""ComfyUI's console only sees warnings unless the level is DEBUG."""
def _console_handler(self) -> logging.StreamHandler:
return next(
h
for h in logging.getLogger("prompt_manager").handlers
if type(h) is logging.StreamHandler
)
def test_records_do_not_propagate_to_comfyui_root_logger(self) -> None:
self.assertFalse(logging.getLogger("prompt_manager").propagate)
def test_console_hides_info_at_the_default_level(self) -> None:
self.manager.update_config({"console_logging": True})
self.assertEqual(self._console_handler().level, logging.WARNING)
self.logger.info("startup chatter")
self.assertEqual(self.manager.get_recent_logs(limit=1)[0]["level"], "INFO")
log_file = pathlib.Path(self.log_dir) / "prompt_manager.log"
self.assertIn("startup chatter", log_file.read_text())
self.manager.update_config({"console_logging": False})
def test_debug_level_opens_the_console(self) -> None:
self.manager.update_config({"console_logging": True, "level": "DEBUG"})
self.assertEqual(self._console_handler().level, logging.DEBUG)
self.manager.update_config({"level": "INFO"})
self.assertEqual(self._console_handler().level, logging.WARNING)
self.manager.update_config({"console_logging": False})
if __name__ == "__main__":
unittest.main()