"""Tests for log_utils module.""" import logging import queue import threading from datetime import datetime from unittest.mock import Mock, patch, MagicMock class TestInterceptHandler: """Tests for InterceptHandler class.""" def test_emit_forwards_to_loguru(self): """Should forward log records to loguru.""" from local_deep_research.utilities.log_utils import InterceptHandler handler = InterceptHandler() # Create a mock log record record = logging.LogRecord( name="test", level=logging.INFO, pathname="test.py", lineno=1, msg="Test message", args=(), exc_info=None, ) with patch( "local_deep_research.utilities.log_utils.logger" ) as mock_logger: mock_logger.level.return_value = Mock(name="INFO") mock_opt = Mock() mock_logger.opt.return_value = mock_opt handler.emit(record) mock_logger.opt.assert_called() mock_opt.log.assert_called() def test_handles_unknown_level(self): """Should handle unknown log levels by using levelno.""" from local_deep_research.utilities.log_utils import InterceptHandler handler = InterceptHandler() record = logging.LogRecord( name="test", level=35, # Non-standard level pathname="test.py", lineno=1, msg="Test", args=(), exc_info=None, ) record.levelname = "CUSTOM" with patch( "local_deep_research.utilities.log_utils.logger" ) as mock_logger: mock_logger.level.side_effect = ValueError("Unknown level") mock_opt = Mock() mock_logger.opt.return_value = mock_opt handler.emit(record) mock_opt.log.assert_called() class TestLogForResearch: """Tests for log_for_research decorator.""" def test_sets_research_id_in_g(self): """Should set research_id in Flask g object.""" from local_deep_research.utilities.log_utils import log_for_research mock_g = MagicMock() @log_for_research def test_func(research_id): return "done" with patch("local_deep_research.utilities.log_utils.g", mock_g): test_func("test-uuid-123") # Check that research_id was set assert mock_g.research_id == "test-uuid-123" def test_removes_research_id_after_function(self): """Should remove research_id from g after function completes.""" from local_deep_research.utilities.log_utils import log_for_research @log_for_research def test_func(research_id): return "result" mock_g = MagicMock() with patch("local_deep_research.utilities.log_utils.g", mock_g): test_func("uuid") mock_g.pop.assert_called_with("research_id") def test_preserves_function_metadata(self): """Should preserve function name and docstring.""" from local_deep_research.utilities.log_utils import log_for_research @log_for_research def documented_func(research_id): """My documentation.""" pass assert documented_func.__name__ == "documented_func" assert documented_func.__doc__ == "My documentation." def test_passes_args_and_kwargs(self): """Should pass arguments correctly.""" from local_deep_research.utilities.log_utils import log_for_research @log_for_research def test_func(research_id, arg1, kwarg1=None): return (research_id, arg1, kwarg1) mock_g = MagicMock() with patch("local_deep_research.utilities.log_utils.g", mock_g): result = test_func("uuid", "value1", kwarg1="value2") assert result == ("uuid", "value1", "value2") class TestDatabaseSink: """Tests for database_sink function.""" def test_creates_log_entry_dict(self): """Should create log entry dictionary from message.""" from local_deep_research.utilities.log_utils import database_sink mock_message = Mock() mock_message.record = { "time": datetime.now(), "message": "Test log message", "name": "test_module", "function": "test_function", "line": 42, "level": Mock(name="INFO"), # research_id is required — ResearchLog rows are research-scoped, # logs with no research context are filtered out at the queue # boundary (they'd never reach the panel anyway). "extra": {"research_id": "rid-1"}, } with patch( "local_deep_research.utilities.log_utils.has_app_context", return_value=False, ): with patch( "local_deep_research.utilities.log_utils._log_queue" ) as mock_queue: database_sink(mock_message) # Should queue the log since we're not in app context mock_queue.put_nowait.assert_called_once() def test_queues_log_from_non_main_thread(self): """Should queue log when not in main thread.""" from local_deep_research.utilities.log_utils import database_sink import local_deep_research.utilities.log_utils as module mock_message = Mock() mock_message.record = { "time": datetime.now(), "message": "Test message", "name": "module", "function": "func", "line": 1, "level": Mock(name="DEBUG"), "extra": {"research_id": "test-uuid"}, } # Mock has_app_context to return True but thread name is not MainThread mock_thread = Mock() mock_thread.name = "WorkerThread" with patch.object(module, "has_app_context", return_value=True): with patch.object( module, "_get_research_id", return_value="test-uuid" ): with patch.object( threading, "current_thread", return_value=mock_thread ): with patch.object(module, "_log_queue") as mock_queue: database_sink(mock_message) # Should queue since not MainThread mock_queue.put_nowait.assert_called_once() def test_handles_full_queue_gracefully(self): """Should not raise when queue is full.""" from local_deep_research.utilities.log_utils import database_sink mock_message = Mock() mock_message.record = { "time": datetime.now(), "message": "Test", "name": "mod", "function": "f", "line": 1, "level": Mock(name="INFO"), "extra": {}, } with patch( "local_deep_research.utilities.log_utils.has_app_context", return_value=False, ): with patch( "local_deep_research.utilities.log_utils._log_queue" ) as mock_queue: mock_queue.put_nowait.side_effect = queue.Full() # Should not raise database_sink(mock_message) def test_writes_to_database_in_main_thread(self): """Should write to database when in main thread with app context.""" from local_deep_research.utilities.log_utils import database_sink import local_deep_research.utilities.log_utils as module mock_message = Mock() mock_message.record = { "time": datetime.now(), "message": "Test message", "name": "test_module", "function": "test_func", "line": 42, "level": Mock(name="INFO"), "extra": {"username": "testuser", "research_id": "test-uuid"}, } mock_thread = Mock() mock_thread.name = "MainThread" mock_session = MagicMock() mock_cm = MagicMock() mock_cm.__enter__ = Mock(return_value=mock_session) mock_cm.__exit__ = Mock(return_value=None) mock_g = Mock() mock_g.get.return_value = None with patch.object(module, "has_app_context", return_value=True): with patch.object(module, "g", mock_g): with patch.object( threading, "current_thread", return_value=mock_thread ): with patch( "local_deep_research.database.session_context.get_user_db_session", return_value=mock_cm, ): database_sink(mock_message) # Should write to database mock_session.add.assert_called_once() mock_session.commit.assert_called_once() def test_handles_database_error_gracefully(self): """Should not raise on database errors when writing.""" from local_deep_research.utilities.log_utils import database_sink import local_deep_research.utilities.log_utils as module mock_message = Mock() mock_message.record = { "time": datetime.now(), "message": "Test", "name": "mod", "function": "f", "line": 1, "level": Mock(name="INFO"), "extra": {"research_id": "test-uuid"}, } mock_thread = Mock() mock_thread.name = "MainThread" mock_g = Mock() mock_g.get.return_value = None with patch.object(module, "has_app_context", return_value=True): with patch.object(module, "g", mock_g): with patch.object( threading, "current_thread", return_value=mock_thread ): with patch( "local_deep_research.database.session_context.get_user_db_session", side_effect=Exception("DB error"), ): # Should not raise database_sink(mock_message) def test_extracts_research_id_from_record_extra(self): """Should extract research_id from record extra.""" from local_deep_research.utilities.log_utils import database_sink import local_deep_research.utilities.log_utils as module mock_message = Mock() mock_message.record = { "time": datetime.now(), "message": "Test", "name": "mod", "function": "f", "line": 1, "level": Mock(name="INFO"), "extra": {"research_id": "record-uuid"}, } with patch.object(module, "has_app_context", return_value=False): with patch.object(module, "_log_queue") as mock_queue: database_sink(mock_message) # Verify the queued log entry contains the research_id call_args = mock_queue.put_nowait.call_args[0][0] assert call_args["research_id"] == "record-uuid" def test_extracts_research_id_from_flask_g(self): """Should extract research_id from Flask g when not in record.""" from local_deep_research.utilities.log_utils import database_sink import local_deep_research.utilities.log_utils as module mock_message = Mock() mock_message.record = { "time": datetime.now(), "message": "Test", "name": "mod", "function": "f", "line": 1, "level": Mock(name="INFO"), "extra": {}, } mock_g = Mock() mock_g.get.return_value = "flask-uuid" with patch.object(module, "has_app_context", return_value=True): with patch.object(module, "g", mock_g): with patch.object(module, "_log_queue") as mock_queue: # Use non-main thread to queue instead of write mock_thread = Mock() mock_thread.name = "WorkerThread" with patch.object( threading, "current_thread", return_value=mock_thread ): database_sink(mock_message) # Verify the queued log entry contains the research_id call_args = mock_queue.put_nowait.call_args[0][0] assert call_args["research_id"] == "flask-uuid" def test_record_research_id_takes_priority_over_flask(self): """Record research_id should take priority over Flask g.""" from local_deep_research.utilities.log_utils import database_sink import local_deep_research.utilities.log_utils as module mock_message = Mock() mock_message.record = { "time": datetime.now(), "message": "Test", "name": "mod", "function": "f", "line": 1, "level": Mock(name="INFO"), "extra": {"research_id": "record-uuid"}, } mock_g = Mock() mock_g.get.return_value = "flask-uuid" with patch.object(module, "has_app_context", return_value=True): with patch.object(module, "g", mock_g): with patch.object(module, "_log_queue") as mock_queue: # Use non-main thread to queue instead of write mock_thread = Mock() mock_thread.name = "WorkerThread" with patch.object( threading, "current_thread", return_value=mock_thread ): database_sink(mock_message) # Record research_id should win call_args = mock_queue.put_nowait.call_args[0][0] assert call_args["research_id"] == "record-uuid" def test_captures_user_password_from_thread_context(self): """Should snapshot user_password from per-thread research context onto the queue entry, so the daemon can decrypt the per-user SQLCipher DB from a thread that can't read the research thread's ContextVar.""" from local_deep_research.utilities.log_utils import database_sink import local_deep_research.utilities.log_utils as module mock_message = Mock() mock_message.record = { "time": datetime.now(), "message": "Test", "name": "mod", "function": "f", "line": 1, "level": Mock(name="INFO"), "extra": {"research_id": "rid-1"}, } fake_ctx = { "research_id": "rid-1", "username": "alice", "user_password": "pw-from-thread-ctx", # gitleaks:allow } with patch.object( module, "_get_research_context_fallback", return_value=fake_ctx ): with patch.object(module, "has_app_context", return_value=False): with patch.object(module, "_log_queue") as mock_queue: database_sink(mock_message) call_args = mock_queue.put_nowait.call_args[0][0] assert call_args["username"] == "alice" assert call_args["user_password"] == "pw-from-thread-ctx" def test_skips_persistence_when_no_research_or_username(self): """Logs without a research_id AND without a username must be dropped at the sink, not queued. ResearchLog is research-scoped — system DEBUG logs (auth, settings, etc.) attached via flask_session would churn through the queue and never resolve to a valid row.""" from local_deep_research.utilities.log_utils import database_sink import local_deep_research.utilities.log_utils as module mock_message = Mock() mock_message.record = { "time": datetime.now(), "message": "system debug log", "name": "mod", "function": "f", "line": 1, "level": Mock(name="DEBUG"), "extra": {}, } # No research context anywhere — neither thread context nor Flask. with patch.object( module, "_get_research_context_fallback", return_value=None ): with patch.object(module, "has_app_context", return_value=False): with patch.object(module, "_log_queue") as mock_queue: database_sink(mock_message) mock_queue.put_nowait.assert_not_called() class TestTruncateForDatabase: """Tests for _truncate_for_database helper and database_sink integration. Mirrors the cap discipline of _truncate_for_frontend: a long message is cut at DATABASE_MESSAGE_MAX_LENGTH chars and a short indicator is appended that reports the original length. Stops langgraph fetch logs (which inline 10 KB page bodies) from being persisted in full into ResearchLog rows. """ def test_short_message_passes_through_unchanged(self): from local_deep_research.utilities.log_utils import ( DATABASE_MESSAGE_MAX_LENGTH, _truncate_for_database, ) short = "small payload" assert len(short) < DATABASE_MESSAGE_MAX_LENGTH assert _truncate_for_database(short) == short def test_message_at_exact_cap_is_not_truncated(self): """The cap is inclusive — a message of exactly the cap length must pass through unchanged.""" from local_deep_research.utilities.log_utils import ( DATABASE_MESSAGE_MAX_LENGTH, _truncate_for_database, ) exact = "Y" * DATABASE_MESSAGE_MAX_LENGTH out = _truncate_for_database(exact) assert out == exact assert "truncated" not in out def test_long_message_truncated_with_indicator(self): from local_deep_research.utilities.log_utils import ( DATABASE_MESSAGE_MAX_LENGTH, _truncate_for_database, ) big = "X" * (DATABASE_MESSAGE_MAX_LENGTH + 5000) out = _truncate_for_database(big) assert out.startswith("X" * DATABASE_MESSAGE_MAX_LENGTH) assert "truncated" in out assert str(len(big)) in out # original length is surfaced # Indicator overhead is bounded (~100 chars), well under the cap. assert len(out) < DATABASE_MESSAGE_MAX_LENGTH + 200 def test_database_sink_queues_truncated_message(self): """database_sink must apply the cap BEFORE queueing so the 10 KB blob never sits in _log_queue (bounded to 1000 entries; without the cap, that's a 10 MB worst-case transient).""" from local_deep_research.utilities.log_utils import ( DATABASE_MESSAGE_MAX_LENGTH, database_sink, ) big = "Z" * (DATABASE_MESSAGE_MAX_LENGTH + 1000) mock_message = Mock() mock_message.record = { "time": datetime.now(), "message": big, "name": "test_module", "function": "test_function", "line": 1, "level": Mock(name="INFO"), "extra": {"research_id": "rid-truncate"}, } with patch( "local_deep_research.utilities.log_utils.has_app_context", return_value=False, ): with patch( "local_deep_research.utilities.log_utils._log_queue" ) as mock_queue: database_sink(mock_message) mock_queue.put_nowait.assert_called_once() queued = mock_queue.put_nowait.call_args[0][0] # The dict put on the queue carries the truncated string, not the # original 10KB+ blob. assert len(queued["message"]) < len(big) assert queued["message"].startswith("Z" * DATABASE_MESSAGE_MAX_LENGTH) assert "truncated" in queued["message"] class TestFrontendProgressSink: """Tests for frontend_progress_sink function.""" def test_skips_when_no_research_id(self): """Should skip when no research_id is available.""" from local_deep_research.utilities.log_utils import ( frontend_progress_sink, ) mock_message = Mock() mock_message.record = { "message": "Test", "level": Mock(name="INFO"), "time": Mock(isoformat=Mock(return_value="2024-01-01T00:00:00")), "extra": {}, } with patch( "local_deep_research.utilities.log_utils._get_research_id", return_value=None, ): with patch( "local_deep_research.utilities.log_utils.SocketIOService" ) as mock_socket: frontend_progress_sink(mock_message) # Should not emit anything mock_socket.return_value.emit_to_subscribers.assert_not_called() def test_emits_to_subscribers_with_research_id(self): """Should emit to subscribers when research_id is present.""" from local_deep_research.utilities.log_utils import ( frontend_progress_sink, ) mock_message = Mock() mock_message.record = { "message": "Progress update", "level": Mock(name="INFO"), "time": Mock(isoformat=Mock(return_value="2024-01-01T12:00:00")), "extra": {"research_id": "test-uuid"}, } with patch( "local_deep_research.utilities.log_utils._get_research_id", return_value="test-uuid", ): with patch( "local_deep_research.utilities.log_utils.SocketIOService" ) as mock_socket: frontend_progress_sink(mock_message) mock_socket.return_value.emit_to_subscribers.assert_called_once() call_args = ( mock_socket.return_value.emit_to_subscribers.call_args ) assert call_args[0][0] == "progress" assert call_args[0][1] == "test-uuid" def test_short_messages_pass_through_unchanged(self): """Messages under the cap must reach the wire byte-for-byte.""" from local_deep_research.utilities.log_utils import ( FRONTEND_MESSAGE_MAX_LENGTH, frontend_progress_sink, ) short_msg = "small payload" assert len(short_msg) < FRONTEND_MESSAGE_MAX_LENGTH mock_message = Mock() mock_message.record = { "message": short_msg, "level": Mock(name="INFO"), "time": Mock(isoformat=Mock(return_value="2024-01-01T00:00:00")), "extra": {"research_id": "rid"}, } with ( patch( "local_deep_research.utilities.log_utils._get_research_id", return_value="rid", ), patch( "local_deep_research.utilities.log_utils.SocketIOService" ) as mock_socket, ): frontend_progress_sink(mock_message) payload = mock_socket.return_value.emit_to_subscribers.call_args[0][2] assert payload["log_entry"]["message"] == short_msg def test_long_messages_truncated_with_indicator(self): """Messages exceeding the cap must be truncated and carry a clear indicator that points the user at the server logs for the full text. Other log levels / sinks are unaffected by this sink.""" from local_deep_research.utilities.log_utils import ( FRONTEND_MESSAGE_MAX_LENGTH, frontend_progress_sink, ) big_msg = "X" * (FRONTEND_MESSAGE_MAX_LENGTH + 5000) mock_message = Mock() mock_message.record = { "message": big_msg, "level": Mock(name="INFO"), "time": Mock(isoformat=Mock(return_value="2024-01-01T00:00:00")), "extra": {"research_id": "rid"}, } with ( patch( "local_deep_research.utilities.log_utils._get_research_id", return_value="rid", ), patch( "local_deep_research.utilities.log_utils.SocketIOService" ) as mock_socket, ): frontend_progress_sink(mock_message) payload = mock_socket.return_value.emit_to_subscribers.call_args[0][2] out = payload["log_entry"]["message"] # Truncated to the cap plus indicator assert out.startswith("X" * FRONTEND_MESSAGE_MAX_LENGTH) assert "truncated" in out assert str(len(big_msg)) in out # original length surfaced # Sanity: cap + indicator is still much smaller than the original assert len(out) < len(big_msg) def test_message_at_exact_cap_is_not_truncated(self): """A message of exactly ``FRONTEND_MESSAGE_MAX_LENGTH`` chars must pass through unchanged — the cap is inclusive.""" from local_deep_research.utilities.log_utils import ( FRONTEND_MESSAGE_MAX_LENGTH, frontend_progress_sink, ) exact = "Y" * FRONTEND_MESSAGE_MAX_LENGTH mock_message = Mock() mock_message.record = { "message": exact, "level": Mock(name="INFO"), "time": Mock(isoformat=Mock(return_value="2024-01-01T00:00:00")), "extra": {"research_id": "rid"}, } with ( patch( "local_deep_research.utilities.log_utils._get_research_id", return_value="rid", ), patch( "local_deep_research.utilities.log_utils.SocketIOService" ) as mock_socket, ): frontend_progress_sink(mock_message) payload = mock_socket.return_value.emit_to_subscribers.call_args[0][2] assert payload["log_entry"]["message"] == exact assert "truncated" not in payload["log_entry"]["message"] class TestFlushLogQueue: """Tests for flush_log_queue function.""" def test_flushes_all_queued_logs(self): """Should flush all logs from queue.""" from local_deep_research.utilities.log_utils import flush_log_queue import local_deep_research.utilities.log_utils as module log_entries = [ { "timestamp": datetime.now(), "message": "Log 1", "module": "mod", "function": "f", "line_no": 1, "level": "INFO", "research_id": None, "username": None, }, { "timestamp": datetime.now(), "message": "Log 2", "module": "mod", "function": "f", "line_no": 2, "level": "INFO", "research_id": None, "username": None, }, ] mock_session = MagicMock() mock_cm = MagicMock() mock_cm.__enter__ = Mock(return_value=mock_session) mock_cm.__exit__ = Mock(return_value=None) with patch.object(module, "_log_queue") as mock_queue: mock_queue.empty.side_effect = [False, False, True] mock_queue.get_nowait.side_effect = log_entries + [queue.Empty()] with patch( "local_deep_research.database.session_context.get_user_db_session", return_value=mock_cm, ): flush_log_queue() # Should have written 2 logs assert mock_session.add.call_count == 2 assert mock_session.commit.call_count == 2 def test_handles_empty_queue(self): """Should handle empty queue gracefully.""" from local_deep_research.utilities.log_utils import flush_log_queue with patch( "local_deep_research.utilities.log_utils._log_queue" ) as mock_queue: mock_queue.empty.return_value = True # Should not raise flush_log_queue() class TestConfigLogger: """Tests for config_logger function.""" def test_configures_logger(self): """Should configure logger with sinks.""" from local_deep_research.utilities.log_utils import config_logger with patch( "local_deep_research.utilities.log_utils.logger" ) as mock_logger: config_logger("test_app") mock_logger.enable.assert_called_with("local_deep_research") mock_logger.remove.assert_called_once() # Should add multiple sinks assert mock_logger.add.call_count >= 3 def test_adds_file_logging_when_enabled(self): """Should add file logging when environment variable is set.""" from local_deep_research.utilities.log_utils import config_logger with patch.dict("os.environ", {"LDR_ENABLE_FILE_LOGGING": "true"}): with patch( "local_deep_research.utilities.log_utils.logger" ) as mock_logger: config_logger("test_app") # Should add 4 sinks (stderr, database, frontend, file) assert mock_logger.add.call_count >= 4 def test_creates_milestone_level(self): """Should create MILESTONE log level.""" from local_deep_research.utilities.log_utils import config_logger with patch( "local_deep_research.utilities.log_utils.logger" ) as mock_logger: config_logger("test_app") mock_logger.level.assert_called() def test_handles_existing_milestone_level(self): """Should handle case where MILESTONE level already exists.""" from local_deep_research.utilities.log_utils import config_logger with patch( "local_deep_research.utilities.log_utils.logger" ) as mock_logger: mock_logger.level.side_effect = ValueError("Level already exists") # Should not raise config_logger("test_app") class TestWriteLogToDatabase: """Tests for _write_log_to_database function.""" def test_writes_research_log(self): """Should write ResearchLog to database.""" from local_deep_research.utilities.log_utils import ( _write_log_to_database, ) log_entry = { "timestamp": datetime.now(), "message": "Test message", "module": "test_module", "function": "test_func", "line_no": 42, "level": "INFO", "research_id": "test-uuid", "username": "testuser", } mock_session = MagicMock() mock_cm = MagicMock() mock_cm.__enter__ = Mock(return_value=mock_session) mock_cm.__exit__ = Mock(return_value=None) with patch( "local_deep_research.database.session_context.get_user_db_session", return_value=mock_cm, ): _write_log_to_database(log_entry) mock_session.add.assert_called_once() mock_session.commit.assert_called_once() def test_handles_database_error_gracefully(self): """Should not raise on database errors.""" from local_deep_research.utilities.log_utils import ( _write_log_to_database, ) log_entry = { "timestamp": datetime.now(), "message": "Test", "module": "mod", "function": "f", "line_no": 1, "level": "INFO", "research_id": None, "username": None, } with patch( "local_deep_research.database.session_context.get_user_db_session", side_effect=Exception("DB error"), ): # Should not raise _write_log_to_database(log_entry) def test_passes_user_password_to_get_user_db_session(self): """The password snapshotted onto the queue entry by database_sink must be forwarded to get_user_db_session as the password= kwarg. Without this the daemon thread cannot decrypt the per-user SQLCipher DB and every research-thread MILESTONE log is silently dropped.""" from local_deep_research.utilities.log_utils import ( _write_log_to_database, ) log_entry = { "timestamp": datetime.now(), "message": "milestone msg", "module": "mod", "function": "f", "line_no": 1, "level": "MILESTONE", "research_id": "rid-1", "username": "alice", "user_password": "pw-from-queue", # gitleaks:allow } mock_session = MagicMock() mock_cm = MagicMock() mock_cm.__enter__ = Mock(return_value=mock_session) mock_cm.__exit__ = Mock(return_value=None) with patch( "local_deep_research.database.session_context.get_user_db_session", return_value=mock_cm, ) as mock_get_session: _write_log_to_database(log_entry) mock_get_session.assert_called_once() call_args = mock_get_session.call_args # username is positional (first arg). assert call_args.args[0] == "alice" # password must be passed as the kwarg the daemon uses for # SQLCipher decryption. assert call_args.kwargs.get("password") == "pw-from-queue" class TestLogQueueProcessorDaemon: """Tests for start_log_queue_processor / stop_log_queue_processor.""" def _make_fake_app(self): """Return a Flask-app-like stub that supports ``with app.app_context()``.""" fake_app = MagicMock() ctx = MagicMock() ctx.__enter__ = Mock(return_value=None) ctx.__exit__ = Mock(return_value=False) fake_app.app_context.return_value = ctx return fake_app def test_spawns_daemon_thread(self): # audit: PUNCHLIST reviewed 2026-05 — KEEP (OK). from local_deep_research.utilities import log_utils app = self._make_fake_app() try: thread = log_utils.start_log_queue_processor(app) assert isinstance(thread, threading.Thread) assert thread.daemon is True assert thread.is_alive() assert thread.name == "log-queue-processor" finally: log_utils.stop_log_queue_processor(timeout=2.0) def test_is_idempotent(self): from local_deep_research.utilities import log_utils app = self._make_fake_app() try: first = log_utils.start_log_queue_processor(app) second = log_utils.start_log_queue_processor(app) assert first is second finally: log_utils.stop_log_queue_processor(timeout=2.0) def test_stop_joins_thread(self): # audit: PUNCHLIST reviewed 2026-05 — KEEP (OK). from local_deep_research.utilities import log_utils app = self._make_fake_app() thread = log_utils.start_log_queue_processor(app) log_utils.stop_log_queue_processor(timeout=2.0) assert not thread.is_alive() def test_stop_does_not_clear_ref_when_join_times_out(self): """If the daemon doesn't exit before join() times out, the module-level reference must stay populated — otherwise a subsequent start would happily spawn a second daemon competing for the same queue.""" from local_deep_research.utilities import log_utils # Stand-in thread that ignores the stop signal so join() always # times out. Daemon=True so it doesn't keep the test process alive. live_thread = threading.Thread( target=lambda: threading.Event().wait(), daemon=True ) live_thread.start() log_utils._queue_processor_thread = live_thread try: log_utils.stop_log_queue_processor(timeout=0.05) assert log_utils._queue_processor_thread is live_thread, ( "thread reference cleared despite join timing out — would " "let start_log_queue_processor spawn a duplicate daemon" ) finally: log_utils._queue_processor_thread = None