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
This commit is contained in:
agessaman
2026-07-07 07:43:27 -07:00
parent fd43d86ea8
commit e6d4b68d01
4 changed files with 428 additions and 124 deletions
+13 -1
View File
@@ -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
+52 -12
View File
@@ -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
+159 -111
View File
@@ -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)
+204
View File
@@ -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 == {}