mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-07-28 04:23:22 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 == {}
|
||||
Reference in New Issue
Block a user