From e6d4b68d0197ddfc28a5c5230faf850a867cec6d Mon Sep 17 00:00:00 2001 From: agessaman Date: Tue, 7 Jul 2026 07:43:27 -0700 Subject: [PATCH] fix: fail companion init loudly when persisted state cannot be loaded A transient SQLite error during boot made companion_load_channels/ contacts/messages swallow the exception and return [], which is indistinguishable from "no data". The Public-channel backfill then ran over the empty store, so clients saw their channels wiped and later saves could overwrite the persisted state. - companion_load_{contacts,channels,messages} now return None on error vs [] for genuinely empty, and log the companion hash - add companion_count_{channels,messages} helpers - extract shared _restore_companion_state used by both the boot and hot-reload companion paths; each load is cross-checked against the table's row count for the hash, retried once after a short delay, and raises CompanionStateLoadError if it still cannot load, aborting companion init (and the Public backfill) instead of starting empty - log restored row counts per companion; log when the channel store rejects a persisted channel (unchecked channels.set return) - trim_companion_contacts_to_fit refuses to trim on a failed load --- repeater/companion/utils.py | 14 +- repeater/data_acquisition/sqlite_handler.py | 64 ++++- repeater/main.py | 270 ++++++++++++-------- tests/test_companion_state_load.py | 204 +++++++++++++++ 4 files changed, 428 insertions(+), 124 deletions(-) create mode 100644 tests/test_companion_state_load.py diff --git a/repeater/companion/utils.py b/repeater/companion/utils.py index c13a569..3afeeb1 100644 --- a/repeater/companion/utils.py +++ b/repeater/companion/utils.py @@ -42,6 +42,14 @@ class CompanionContactCapacityError(Exception): ) +class CompanionStateLoadError(Exception): + """Persisted companion state exists in SQLite but could not be loaded. + + Raised at companion init so the companion fails loudly instead of starting + with an empty store (which would present to clients as wiped channels or + contacts and let subsequent saves overwrite the persisted state).""" + + def normalize_companion_identity_key(identity_key: str) -> str: """Strip whitespace and remove optional 0x prefix so fromhex() is consistent across installs.""" s = identity_key.strip() @@ -207,11 +215,15 @@ def trim_companion_contacts_to_fit( Raises: ValueError: favourites alone exceed ``max_contacts`` (cannot trim). - RuntimeError: persisting the trimmed contact list failed. + RuntimeError: loading or persisting the contact list failed. """ if sqlite_handler is None: return 0 contacts = sqlite_handler.companion_load_contacts(companion_hash) + if contacts is None: + raise RuntimeError( + f"Failed to load persisted contacts for {companion_hash}; refusing to trim" + ) keep, removed = select_companion_contacts_to_trim(contacts, max_contacts) if not removed: return 0 diff --git a/repeater/data_acquisition/sqlite_handler.py b/repeater/data_acquisition/sqlite_handler.py index 0ace176..d50165d 100644 --- a/repeater/data_acquisition/sqlite_handler.py +++ b/repeater/data_acquisition/sqlite_handler.py @@ -2392,8 +2392,12 @@ class SQLiteHandler: logger.error(f"Failed to count companion contacts: {e}") return 0 - def companion_load_contacts(self, companion_hash: str) -> List[Dict]: - """Load contacts for a companion from storage.""" + def companion_load_contacts(self, companion_hash: str) -> Optional[List[Dict]]: + """Load contacts for a companion from storage. + + Returns [] when the companion has no persisted contacts, or None when + the load failed — callers must not treat a failed load as "no data". + """ try: with self._connect() as conn: conn.row_factory = sqlite3.Row @@ -2407,8 +2411,8 @@ class SQLiteHandler: ) return [dict(row) for row in cursor.fetchall()] except Exception as e: - logger.error(f"Failed to load companion contacts: {e}") - return [] + logger.error(f"Failed to load companion contacts for {companion_hash}: {e}") + return None def companion_save_contacts(self, companion_hash: str, contacts: List[Dict]) -> bool: """Replace all contacts for a companion in storage using batch insert.""" @@ -2619,8 +2623,26 @@ class SQLiteHandler: logger.error(f"Failed to save companion prefs: {e}") return False - def companion_load_channels(self, companion_hash: str) -> List[Dict]: - """Load channels for a companion from storage.""" + def companion_count_channels(self, companion_hash: str) -> int: + """Return the number of persisted channels for a companion.""" + try: + with self._connect() as conn: + cursor = conn.execute( + "SELECT COUNT(*) FROM companion_channels WHERE companion_hash = ?", + (companion_hash,), + ) + row = cursor.fetchone() + return int(row[0]) if row else 0 + except Exception as e: + logger.error(f"Failed to count companion channels: {e}") + return 0 + + def companion_load_channels(self, companion_hash: str) -> Optional[List[Dict]]: + """Load channels for a companion from storage. + + Returns [] when the companion has no persisted channels, or None when + the load failed — callers must not treat a failed load as "no data". + """ try: with self._connect() as conn: conn.row_factory = sqlite3.Row @@ -2633,8 +2655,8 @@ class SQLiteHandler: ) return [dict(row) for row in cursor.fetchall()] except Exception as e: - logger.error(f"Failed to load companion channels: {e}") - return [] + logger.error(f"Failed to load companion channels for {companion_hash}: {e}") + return None def companion_save_channels(self, companion_hash: str, channels: List[Dict]) -> bool: """Replace all channels for a companion in storage using batch insert.""" @@ -2670,8 +2692,26 @@ class SQLiteHandler: logger.error(f"Failed to save companion channels: {e}") return False - def companion_load_messages(self, companion_hash: str, limit: int = 100) -> List[Dict]: - """Load queued messages for a companion (oldest first for queue order).""" + def companion_count_messages(self, companion_hash: str) -> int: + """Return the number of persisted queued messages for a companion.""" + try: + with self._connect() as conn: + cursor = conn.execute( + "SELECT COUNT(*) FROM companion_messages WHERE companion_hash = ?", + (companion_hash,), + ) + row = cursor.fetchone() + return int(row[0]) if row else 0 + except Exception as e: + logger.error(f"Failed to count companion messages: {e}") + return 0 + + def companion_load_messages(self, companion_hash: str, limit: int = 100) -> Optional[List[Dict]]: + """Load queued messages for a companion (oldest first for queue order). + + Returns [] when the companion has no persisted messages, or None when + the load failed — callers must not treat a failed load as "no data". + """ try: with self._connect() as conn: conn.row_factory = sqlite3.Row @@ -2689,8 +2729,8 @@ class SQLiteHandler: msg["sender_prefix"] = bytes.fromhex(msg.get("sender_prefix") or "") return rows except Exception as e: - logger.error(f"Failed to load companion messages: {e}") - return [] + logger.error(f"Failed to load companion messages for {companion_hash}: {e}") + return None def companion_push_message( self, companion_hash: str, msg: Dict, max_messages: Optional[int] = None diff --git a/repeater/main.py b/repeater/main.py index 7612aeb..22629c9 100644 --- a/repeater/main.py +++ b/repeater/main.py @@ -9,6 +9,7 @@ import time from repeater.companion.utils import ( CompanionContactCapacityError, + CompanionStateLoadError, effective_max_contacts, enforce_companion_contact_capacity, format_companion_bridge_limits, @@ -37,6 +38,43 @@ from repeater.web.http_server import HTTPStatsServer, _log_buffer logger = logging.getLogger("RepeaterDaemon") +_COMPANION_LOAD_RETRY_DELAY_SEC = 0.5 + + +async def _load_companion_rows_verified( + loader, counter, kind: str, companion_hash_str: str, name: str, **loader_kwargs +): + """Load persisted companion rows, cross-checking empty results against the table. + + A transient SQLite error at boot must not present as "no data" — the + companion would start with an empty store and later saves would overwrite + the persisted state. Retries once after a short delay when the load failed + (loader returned None) or returned empty while the table has rows for this + companion; raises CompanionStateLoadError if it still cannot load. + + Returns (rows, stored_count). + """ + stored = 0 + for attempt in (1, 2): + rows = loader(companion_hash_str, **loader_kwargs) + stored = counter(companion_hash_str) + if rows is not None and (rows or stored == 0): + return rows, stored + if attempt == 1: + logger.warning( + "Companion %s ('%s'): %s load %s but table has %d row(s); retrying once", + companion_hash_str, + name, + kind, + "failed" if rows is None else "returned empty", + stored, + ) + await asyncio.sleep(_COMPANION_LOAD_RETRY_DELAY_SEC) + raise CompanionStateLoadError( + f"Companion {companion_hash_str} ('{name}'): could not load persisted {kind} " + f"(table has {stored} row(s)); refusing to start with an empty store" + ) + class RepeaterDaemon: def __init__(self, config: dict, radio=None): @@ -494,7 +532,6 @@ class RepeaterDaemon: async def _load_companion_identities(self) -> None: """Load companion identities from config and create CompanionBridge + frame server for each.""" from openhop_core import LocalIdentity - from openhop_core.companion.models import Channel from repeater.companion import CompanionFrameServer, RepeaterCompanionBridge @@ -613,64 +650,13 @@ class RepeaterDaemon: **bridge_kwargs, ) - # Load contacts from SQLite + # Restore persisted state (contacts/channels/messages) from SQLite. + # Raises CompanionStateLoadError instead of continuing with an + # empty store when persisted rows exist but cannot be loaded. if sqlite_handler: - contact_rows = sqlite_handler.companion_load_contacts(companion_hash_str) - if contact_rows: - records = [] - for row in contact_rows: - d = dict(row) - d["public_key"] = d.pop("pubkey", d.get("public_key", b"")) - records.append(d) - bridge.contacts.load_from_dicts(records) - - # Load channels from SQLite (normalize secret to 32 bytes to match - # CompanionBase.set_channel and GroupTextHandler/PacketBuilder) - channel_rows = sqlite_handler.companion_load_channels(companion_hash_str) - for row in channel_rows: - s = row.get("secret", b"") - if isinstance(s, bytes): - raw = s - elif isinstance(s, (bytearray, memoryview)): - raw = bytes(s) - elif s: - raw = bytes.fromhex(s if isinstance(s, str) else str(s)) - else: - raw = b"" - if len(raw) < 32: - raw = raw + b"\x00" * (32 - len(raw)) - elif len(raw) > 32: - raw = raw[:32] - ch = Channel(name=row.get("name", ""), secret=raw) - bridge.channels.set(row.get("channel_idx", 0), ch) - - # Preload queued messages from SQLite into bridge, bounded by - # offline_queue_size (0 disables offline storage entirely). - retention = getattr(bridge.message_queue, "_max_size", None) - if retention != 0: - for msg_dict in sqlite_handler.companion_load_messages( - companion_hash_str, limit=retention or 100 - ): - from openhop_core.companion.models import QueuedMessage - - sk = msg_dict.get("sender_key", b"") - if isinstance(sk, str): - sk = bytes.fromhex(sk) - sp = msg_dict.get("sender_prefix", b"") - if isinstance(sp, str): - sp = bytes.fromhex(sp) if sp else b"" - bridge.message_queue.push( - QueuedMessage( - sender_key=sk, - txt_type=msg_dict.get("txt_type", 0), - timestamp=msg_dict.get("timestamp", 0), - text=msg_dict.get("text", ""), - is_channel=bool(msg_dict.get("is_channel", False)), - channel_idx=msg_dict.get("channel_idx", 0), - path_len=msg_dict.get("path_len", 0), - sender_prefix=sp, - ) - ) + await self._restore_companion_state( + sqlite_handler, bridge, companion_hash_str, name + ) # Ensure public channel (0) exists with default key for new companions from repeater.companion.constants import DEFAULT_PUBLIC_CHANNEL_SECRET @@ -712,9 +698,121 @@ class RepeaterDaemon: except CompanionContactCapacityError as e: logger.error("%s", e) + except CompanionStateLoadError as e: + logger.error("Companion init aborted: %s", e) except Exception as e: logger.error(f"Failed to load companion '{name}': {e}", exc_info=True) + async def _restore_companion_state( + self, sqlite_handler, bridge, companion_hash_str: str, name: str + ) -> None: + """Restore persisted contacts/channels/messages from SQLite into a bridge. + + Each load is cross-checked against the table's row count for this + companion and retried once on mismatch; raises CompanionStateLoadError + when persisted rows exist but cannot be loaded, so the companion fails + init loudly instead of starting with an empty store. + """ + from openhop_core.companion.models import Channel, QueuedMessage + + contact_rows, contact_count = await _load_companion_rows_verified( + sqlite_handler.companion_load_contacts, + sqlite_handler.companion_count_contacts, + "contacts", + companion_hash_str, + name, + ) + if contact_rows: + records = [] + for row in contact_rows: + d = dict(row) + d["public_key"] = d.pop("pubkey", d.get("public_key", b"")) + records.append(d) + bridge.contacts.load_from_dicts(records) + + # Load channels (normalize secret to 32 bytes to match + # CompanionBase.set_channel and GroupTextHandler/PacketBuilder) + channel_rows, channel_count = await _load_companion_rows_verified( + sqlite_handler.companion_load_channels, + sqlite_handler.companion_count_channels, + "channels", + companion_hash_str, + name, + ) + for row in channel_rows: + s = row.get("secret", b"") + if isinstance(s, bytes): + raw = s + elif isinstance(s, (bytearray, memoryview)): + raw = bytes(s) + elif s: + raw = bytes.fromhex(s if isinstance(s, str) else str(s)) + else: + raw = b"" + if len(raw) < 32: + raw = raw + b"\x00" * (32 - len(raw)) + elif len(raw) > 32: + raw = raw[:32] + idx = row.get("channel_idx", 0) + ch = Channel(name=row.get("name", ""), secret=raw) + if not bridge.channels.set(idx, ch): + logger.error( + "Companion %s ('%s'): channel store rejected persisted channel " + "idx=%r name=%r (index out of range?)", + companion_hash_str, + name, + idx, + row.get("name", ""), + ) + + # Preload queued messages, bounded by offline_queue_size (0 disables + # offline storage entirely). + loaded_messages = 0 + message_count = 0 + retention = getattr(bridge.message_queue, "max_size", None) + if retention != 0: + message_rows, message_count = await _load_companion_rows_verified( + sqlite_handler.companion_load_messages, + sqlite_handler.companion_count_messages, + "messages", + companion_hash_str, + name, + limit=retention or 100, + ) + loaded_messages = len(message_rows) + for msg_dict in message_rows: + sk = msg_dict.get("sender_key", b"") + if isinstance(sk, str): + sk = bytes.fromhex(sk) + sp = msg_dict.get("sender_prefix", b"") + if isinstance(sp, str): + sp = bytes.fromhex(sp) if sp else b"" + bridge.message_queue.push( + QueuedMessage( + sender_key=sk, + txt_type=msg_dict.get("txt_type", 0), + timestamp=msg_dict.get("timestamp", 0), + text=msg_dict.get("text", ""), + is_channel=bool(msg_dict.get("is_channel", False)), + channel_idx=msg_dict.get("channel_idx", 0), + path_len=msg_dict.get("path_len", 0), + sender_prefix=sp, + ) + ) + + logger.info( + "Companion %s ('%s'): restored %d/%d contact(s), %d/%d channel(s), " + "%d/%d message(s) from SQLite", + companion_hash_str, + name, + len(contact_rows), + contact_count, + len(channel_rows), + channel_count, + loaded_messages, + message_count, + ) + async def add_companion_from_config(self, comp_config: dict) -> None: """ Load a single companion from config and register it (hot-reload). @@ -722,7 +820,6 @@ class RepeaterDaemon: and registers with identity_manager. Raises on error. """ from openhop_core import LocalIdentity - from openhop_core.companion.models import Channel from repeater.companion import CompanionFrameServer, RepeaterCompanionBridge from repeater.companion.constants import DEFAULT_PUBLIC_CHANNEL_SECRET @@ -808,59 +905,10 @@ class RepeaterDaemon: **bridge_kwargs, ) + # Restore persisted state; raises CompanionStateLoadError when persisted + # rows exist but cannot be loaded (hot-reload callers surface the error). if sqlite_handler: - contact_rows = sqlite_handler.companion_load_contacts(companion_hash_str) - if contact_rows: - records = [] - for row in contact_rows: - d = dict(row) - d["public_key"] = d.pop("pubkey", d.get("public_key", b"")) - records.append(d) - bridge.contacts.load_from_dicts(records) - - channel_rows = sqlite_handler.companion_load_channels(companion_hash_str) - for row in channel_rows: - s = row.get("secret", b"") - if isinstance(s, bytes): - raw = s - elif isinstance(s, (bytearray, memoryview)): - raw = bytes(s) - elif s: - raw = bytes.fromhex(s if isinstance(s, str) else str(s)) - else: - raw = b"" - if len(raw) < 32: - raw = raw + b"\x00" * (32 - len(raw)) - elif len(raw) > 32: - raw = raw[:32] - ch = Channel(name=row.get("name", ""), secret=raw) - bridge.channels.set(row.get("channel_idx", 0), ch) - - retention = getattr(bridge.message_queue, "_max_size", None) - if retention != 0: - for msg_dict in sqlite_handler.companion_load_messages( - companion_hash_str, limit=retention or 100 - ): - from openhop_core.companion.models import QueuedMessage - - sk = msg_dict.get("sender_key", b"") - if isinstance(sk, str): - sk = bytes.fromhex(sk) - sp = msg_dict.get("sender_prefix", b"") - if isinstance(sp, str): - sp = bytes.fromhex(sp) if sp else b"" - bridge.message_queue.push( - QueuedMessage( - sender_key=sk, - txt_type=msg_dict.get("txt_type", 0), - timestamp=msg_dict.get("timestamp", 0), - text=msg_dict.get("text", ""), - is_channel=bool(msg_dict.get("is_channel", False)), - channel_idx=msg_dict.get("channel_idx", 0), - path_len=msg_dict.get("path_len", 0), - sender_prefix=sp, - ) - ) + await self._restore_companion_state(sqlite_handler, bridge, companion_hash_str, name) if bridge.get_channel(0) is None: bridge.set_channel(0, "Public", DEFAULT_PUBLIC_CHANNEL_SECRET) diff --git a/tests/test_companion_state_load.py b/tests/test_companion_state_load.py new file mode 100644 index 0000000..a79d548 --- /dev/null +++ b/tests/test_companion_state_load.py @@ -0,0 +1,204 @@ +"""Companion boot-path hardening: a failed SQLite load must not present as "no data". + +Covers _load_companion_rows_verified retry/verification, _restore_companion_state, +and _load_companion_identities failing companion init loudly when persisted rows +exist but cannot be loaded (instead of booting an empty store and backfilling +the Public channel over it). +""" + +import logging +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +import repeater.main as main_module +from repeater.companion.utils import CompanionStateLoadError +from repeater.main import RepeaterDaemon, _load_companion_rows_verified + +_HASH = "0xab" +_NAME = "comp-test" + + +@pytest.fixture(autouse=True) +def _no_retry_delay(monkeypatch): + monkeypatch.setattr(main_module, "_COMPANION_LOAD_RETRY_DELAY_SEC", 0) + + +class TestLoadCompanionRowsVerified: + @pytest.mark.asyncio + async def test_genuinely_empty_returns_without_retry(self): + loader = MagicMock(return_value=[]) + counter = MagicMock(return_value=0) + rows, stored = await _load_companion_rows_verified(loader, counter, "channels", _HASH, _NAME) + assert rows == [] + assert stored == 0 + assert loader.call_count == 1 + + @pytest.mark.asyncio + async def test_transient_failure_recovers_on_retry(self, caplog): + good_rows = [{"channel_idx": 0, "name": "Public", "secret": b"x"}] + loader = MagicMock(side_effect=[None, good_rows]) + counter = MagicMock(return_value=1) + with caplog.at_level(logging.WARNING): + rows, stored = await _load_companion_rows_verified( + loader, counter, "channels", _HASH, _NAME + ) + assert rows == good_rows + assert stored == 1 + assert loader.call_count == 2 + assert any("retrying once" in r.message for r in caplog.records) + + @pytest.mark.asyncio + async def test_persistent_failure_raises(self): + loader = MagicMock(return_value=None) + counter = MagicMock(return_value=3) + with pytest.raises(CompanionStateLoadError, match="channels"): + await _load_companion_rows_verified(loader, counter, "channels", _HASH, _NAME) + assert loader.call_count == 2 + + @pytest.mark.asyncio + async def test_empty_result_with_stored_rows_raises(self): + # Load "succeeds" with [] while the table has rows for this hash: + # treat as a failed load, not as no data. + loader = MagicMock(return_value=[]) + counter = MagicMock(return_value=5) + with pytest.raises(CompanionStateLoadError, match="5 row"): + await _load_companion_rows_verified(loader, counter, "contacts", _HASH, _NAME) + assert loader.call_count == 2 + + +class TestRestoreCompanionState: + @staticmethod + def _bridge(max_size=100): + bridge = MagicMock() + bridge.message_queue.max_size = max_size + bridge.channels.set.return_value = True + return bridge + + @staticmethod + def _sqlite(contacts=(), channels=(), messages=()): + sqlite = MagicMock() + sqlite.companion_load_contacts.return_value = list(contacts) + sqlite.companion_count_contacts.return_value = len(contacts) + sqlite.companion_load_channels.return_value = list(channels) + sqlite.companion_count_channels.return_value = len(channels) + sqlite.companion_load_messages.return_value = list(messages) + sqlite.companion_count_messages.return_value = len(messages) + return sqlite + + @staticmethod + def _daemon(): + return RepeaterDaemon({"repeater": {"node_name": "n"}, "logging": {}}, radio=object()) + + @pytest.mark.asyncio + async def test_restores_all_state(self): + daemon = self._daemon() + bridge = self._bridge() + sqlite = self._sqlite( + contacts=[{"pubkey": b"\x01" * 32, "name": "c1"}], + channels=[{"channel_idx": 1, "name": "ch1", "secret": b"\x02" * 32}], + messages=[{"sender_key": b"", "text": "hi", "sender_prefix": b""}], + ) + await daemon._restore_companion_state(sqlite, bridge, _HASH, _NAME) + bridge.contacts.load_from_dicts.assert_called_once() + bridge.channels.set.assert_called_once() + assert bridge.channels.set.call_args[0][0] == 1 + bridge.message_queue.push.assert_called_once() + + @pytest.mark.asyncio + async def test_channel_load_failure_raises_before_bridge_touch(self): + daemon = self._daemon() + bridge = self._bridge() + sqlite = self._sqlite() + sqlite.companion_load_channels.return_value = None + sqlite.companion_count_channels.return_value = 2 + with pytest.raises(CompanionStateLoadError): + await daemon._restore_companion_state(sqlite, bridge, _HASH, _NAME) + bridge.channels.set.assert_not_called() + bridge.message_queue.push.assert_not_called() + + @pytest.mark.asyncio + async def test_rejected_channel_set_logs_error(self, caplog): + daemon = self._daemon() + bridge = self._bridge() + bridge.channels.set.return_value = False + sqlite = self._sqlite(channels=[{"channel_idx": 99, "name": "bad", "secret": b""}]) + with caplog.at_level(logging.ERROR): + await daemon._restore_companion_state(sqlite, bridge, _HASH, _NAME) + assert any("rejected persisted channel" in r.message for r in caplog.records) + + @pytest.mark.asyncio + async def test_zero_retention_skips_message_load(self): + daemon = self._daemon() + bridge = self._bridge(max_size=0) + sqlite = self._sqlite() + await daemon._restore_companion_state(sqlite, bridge, _HASH, _NAME) + sqlite.companion_load_messages.assert_not_called() + + +class TestCompanionInitSurfacesLoadFailure: + @staticmethod + def _daemon_with_companion(sqlite): + config = { + "repeater": {"node_name": "n"}, + "logging": {}, + "identities": { + "companions": [ + {"name": _NAME, "identity_key": "11" * 32, "settings": {"tcp_port": 5001}} + ] + }, + } + daemon = RepeaterDaemon(config, radio=object()) + daemon.router = SimpleNamespace(inject_packet=AsyncMock()) + daemon.repeater_handler = SimpleNamespace( + storage=SimpleNamespace(sqlite_handler=sqlite), radio_config={} + ) + return daemon + + @staticmethod + def _failing_sqlite(): + sqlite = MagicMock() + sqlite.companion_count_contacts.return_value = 0 + sqlite.companion_load_contacts.return_value = [] + # Channels table has rows for this companion but every load fails. + sqlite.companion_load_channels.return_value = None + sqlite.companion_count_channels.return_value = 3 + return sqlite + + @pytest.mark.asyncio + async def test_load_companion_identities_aborts_companion(self, caplog): + sqlite = self._failing_sqlite() + daemon = self._daemon_with_companion(sqlite) + with ( + patch("repeater.companion.RepeaterCompanionBridge") as bridge_cls, + patch("repeater.companion.CompanionFrameServer") as server_cls, + caplog.at_level(logging.ERROR), + ): + bridge_cls.return_value.message_queue.max_size = 100 + await daemon._load_companion_identities() + + # Companion init failed loudly: nothing registered, no frame server, + # and no Public-channel backfill over the unloaded store. + assert daemon.companion_bridges == {} + assert daemon.companion_frame_servers == [] + server_cls.assert_not_called() + bridge_cls.return_value.set_channel.assert_not_called() + assert sqlite.companion_load_channels.call_count == 2 # retried once + assert any("Companion init aborted" in r.message for r in caplog.records) + + @pytest.mark.asyncio + async def test_add_companion_from_config_raises(self): + sqlite = self._failing_sqlite() + daemon = self._daemon_with_companion(sqlite) + daemon.identity_manager = SimpleNamespace(named_identities={}) + comp_config = {"name": "hot-comp", "identity_key": "22" * 32, "settings": {}} + with ( + patch("repeater.companion.RepeaterCompanionBridge") as bridge_cls, + patch("repeater.companion.CompanionFrameServer") as server_cls, + ): + bridge_cls.return_value.message_queue.max_size = 100 + with pytest.raises(CompanionStateLoadError): + await daemon.add_companion_from_config(comp_config) + server_cls.assert_not_called() + assert daemon.companion_bridges == {}