Add tests for crash recovery, emergency mode, and lifecycle management

- Introduced new tests for heuristic analysis in crash recovery to validate error diagnosis.
- Added tests for emergency mode to ensure memory concurrency handling with the in-memory database.
- Created a comprehensive suite for lifecycle management, including database provider disposal and identity context teardown to prevent memory leaks.
- Improved existing tests for WebAudioBridge to verify proper event loop handling and resource cleanup.
This commit is contained in:
Sudo-Ivan
2026-01-14 17:59:22 -06:00
parent a8687a0e09
commit 1788aea0c2
6 changed files with 381 additions and 4 deletions
+20
View File
@@ -119,8 +119,28 @@ class TestCrashRecovery(unittest.TestCase):
self.assertIn("!!! APPLICATION CRASH DETECTED !!!", report)
self.assertIn("Type: ValueError", report)
self.assertIn("Message: Simulated error for testing", report)
self.assertIn("Probabilistic Root Cause Analysis:", report)
self.assertIn("Recovery Suggestions:", report)
def test_heuristic_analysis_sqlite(self):
exc_type = type("OperationalError", (Exception,), {})
exc_type.__name__ = "sqlite3.OperationalError"
exc_value = Exception("no such table: config")
diagnosis = {"db_type": "memory"}
causes = self.recovery._analyze_cause(exc_type, exc_value, diagnosis)
self.assertTrue(len(causes) > 0)
self.assertEqual(causes[0]["description"], "In-Memory Database Sync Failure")
def test_heuristic_analysis_asyncio(self):
exc_type = RuntimeError
exc_value = RuntimeError("no current event loop")
diagnosis = {}
causes = self.recovery._analyze_cause(exc_type, exc_value, diagnosis)
self.assertTrue(len(causes) > 0)
self.assertIn("Asynchronous Initialization", causes[0]["description"])
if __name__ == "__main__":
unittest.main()
+44
View File
@@ -1,12 +1,14 @@
import os
import shutil
import tempfile
import threading
from unittest.mock import MagicMock, patch
import pytest
import RNS
from meshchatx.meshchat import ReticulumMeshChat
from meshchatx.src.backend.database.provider import DatabaseProvider
@pytest.fixture
@@ -221,3 +223,45 @@ def test_normal_mode_startup_logic(mock_rns, temp_dir):
# Verify IntegrityManager.save_manifest WAS called
assert mock_integrity_instance.save_manifest.call_count == 1
def test_emergency_mode_memory_concurrency(mock_rns, temp_dir):
"""Verify that :memory: database connection is shared across threads."""
# Reset singleton
DatabaseProvider._instance = None
with (
patch(
"meshchatx.src.backend.identity_context.IdentityContext.start_background_threads",
),
patch("meshchatx.src.backend.identity_context.create_lxmf_router"),
patch("meshchatx.meshchat.WebAudioBridge"),
patch("meshchatx.meshchat.memory_log_handler"),
):
app = ReticulumMeshChat(
identity=mock_rns["id_instance"],
storage_dir=temp_dir,
reticulum_config_dir=temp_dir,
emergency=True,
)
ctx = app.current_context
provider = ctx.database.provider
assert provider.db_path == ":memory:"
# Set value in main thread
test_name = "Emergency Worker"
ctx.config.display_name.set(test_name)
# Simulate another thread by swapping thread-local storage
original_local = provider._local
provider._local = threading.local()
try:
# Should still return the SAME connection object because of the fix
val = ctx.config.display_name.get()
assert val == test_name
finally:
provider._local = original_local
DatabaseProvider._instance = None
+187
View File
@@ -0,0 +1,187 @@
import gc
import os
import shutil
import sqlite3
import tempfile
from unittest.mock import MagicMock, patch
import pytest
import RNS
from hypothesis import given
from hypothesis import strategies as st
from meshchatx.src.backend.database.provider import DatabaseProvider
from meshchatx.src.backend.identity_context import IdentityContext
from meshchatx.src.backend.web_audio_bridge import WebAudioBridge
def test_database_provider_disposal():
"""Test that DatabaseProvider correctly closes connections."""
db_path = os.path.join(tempfile.gettempdir(), "test_disposal.db")
if os.path.exists(db_path):
os.remove(db_path)
# Ensure any existing singleton is cleared
DatabaseProvider._instance = None
try:
provider = DatabaseProvider.get_instance(db_path)
conn = provider.connection
assert isinstance(conn, sqlite3.Connection)
# Test close()
provider.close()
with pytest.raises(sqlite3.ProgrammingError, match="closed database"):
conn.execute("SELECT 1")
# Re-open
conn2 = provider.connection
assert conn2 is not conn
# Test close_all()
provider.close_all()
with pytest.raises(sqlite3.ProgrammingError, match="closed database"):
conn2.execute("SELECT 1")
finally:
if os.path.exists(db_path):
try:
os.remove(db_path)
except Exception:
pass
DatabaseProvider._instance = None
def test_web_audio_bridge_disposal():
"""Test that WebAudioBridge correctly manages clients and cleanup."""
mock_tele_mgr = MagicMock()
mock_config_mgr = MagicMock()
bridge = WebAudioBridge(mock_tele_mgr, mock_config_mgr)
mock_client = MagicMock()
mock_tele = MagicMock()
mock_tele.active_call = True
mock_tele_mgr.telephone = mock_tele
with (
patch("meshchatx.src.backend.web_audio_bridge.WebAudioSource"),
patch("meshchatx.src.backend.web_audio_bridge.WebAudioSink"),
patch("meshchatx.src.backend.web_audio_bridge.Tee"),
patch("meshchatx.src.backend.web_audio_bridge.Pipeline"),
):
bridge.attach_client(mock_client)
assert mock_client in bridge.clients
bridge.on_call_ended()
assert bridge.tx_source is None
assert bridge.rx_sink is None
assert len(bridge.clients) == 0
def test_identity_context_teardown_completeness():
"""Verify that teardown cleans up all major components."""
mock_identity = MagicMock(spec=RNS.Identity)
mock_identity.hash = b"test_hash_32_bytes_long_01234567"
mock_identity.get_private_key.return_value = b"mock_pk"
mock_app = MagicMock()
mock_app.storage_dir = tempfile.mkdtemp()
with (
patch("meshchatx.src.backend.identity_context.Database"),
patch("meshchatx.src.backend.identity_context.ConfigManager"),
patch("meshchatx.src.backend.identity_context.create_lxmf_router"),
patch("meshchatx.src.backend.identity_context.IntegrityManager"),
patch(
"meshchatx.src.backend.identity_context.AutoPropagationManager",
),
):
context = IdentityContext(mock_identity, mock_app)
context.start_background_threads = MagicMock()
context.register_announce_handlers = MagicMock()
context.setup()
# Capture instances
db_instance = context.database
auto_prop_instance = context.auto_propagation_manager
context.teardown()
# Verify component cleanup
db_instance._checkpoint_and_close.assert_called()
auto_prop_instance.stop.assert_called()
assert context.running is False
def test_identity_context_memory_leak():
"""Verify that IdentityContext can be garbage collected after teardown."""
mock_identity = MagicMock(spec=RNS.Identity)
mock_identity.hash = b"leak_test_hash_32_bytes_long_012"
mock_identity.get_private_key.return_value = b"mock_pk"
mock_app = MagicMock()
mock_app.storage_dir = tempfile.mkdtemp()
import weakref
# We use a list to store the ref so we can access it after the function scope
leak_ref = []
def run_lifecycle():
with (
patch("meshchatx.src.backend.identity_context.Database"),
patch("meshchatx.src.backend.identity_context.ConfigManager"),
patch("meshchatx.src.backend.identity_context.create_lxmf_router"),
patch("meshchatx.src.backend.identity_context.IntegrityManager"),
patch("RNS.Transport"),
):
context = IdentityContext(mock_identity, mock_app)
context.start_background_threads = MagicMock()
context.register_announce_handlers = MagicMock()
context.setup()
context.teardown()
leak_ref.append(weakref.ref(context))
# End of with block and function scope should clear 'context'
run_lifecycle()
# Break any potential cycles in the app mock which might have captured the context
mock_app.reset_mock()
# Multiple collection rounds
for _ in range(5):
gc.collect()
# Check if it was collected
assert leak_ref[0]() is None, "IdentityContext was not garbage collected"
@given(st.integers(min_value=1, max_value=3))
def test_identity_context_repeated_lifecycle(n):
"""Fuzz the lifecycle by repeating setup/teardown multiple times with new instances."""
mock_identity = MagicMock(spec=RNS.Identity)
mock_identity.hash = b"fuzz_hash_32_bytes_long_01234567"
mock_identity.get_private_key.return_value = b"mock_pk"
mock_app = MagicMock()
mock_app.storage_dir = tempfile.mkdtemp()
with (
patch("meshchatx.src.backend.identity_context.Database"),
patch("meshchatx.src.backend.identity_context.ConfigManager"),
patch("meshchatx.src.backend.identity_context.create_lxmf_router"),
patch("meshchatx.src.backend.identity_context.IntegrityManager"),
patch("RNS.Transport"),
):
for _ in range(n):
context = IdentityContext(mock_identity, mock_app)
context.start_background_threads = MagicMock()
context.register_announce_handlers = MagicMock()
context.setup()
assert context.running is True
context.teardown()
assert context.running is False
if os.path.exists(mock_app.storage_dir):
shutil.rmtree(mock_app.storage_dir)
+3 -3
View File
@@ -374,9 +374,9 @@ def test_identity_restore_base32_robustness(data):
@given(
st.lists(
st.text(min_size=1).filter(
lambda x: "\n" not in x and x.strip() and x.isalnum()
)
)
lambda x: "\n" not in x and x.strip() and x.isalnum(),
),
),
)
def test_markdown_renderer_list_rendering(items):
if not items:
@@ -0,0 +1,87 @@
import os
import shutil
import tempfile
from unittest.mock import MagicMock, patch
import pytest
from meshchatx.meshchat import ReticulumMeshChat
@pytest.fixture
def temp_dir():
dir_path = tempfile.mkdtemp()
yield dir_path
shutil.rmtree(dir_path)
@pytest.fixture
def mock_rns():
with (
patch("RNS.Reticulum") as mock_reticulum,
patch("RNS.Transport"),
patch("RNS.Identity") as mock_identity,
):
mock_id_instance = MagicMock()
mock_id_instance.hash = b"test_hash_32_bytes_long_01234567"
mock_id_instance.get_private_key.return_value = b"test_key"
mock_identity.return_value = mock_id_instance
yield {"Reticulum": mock_reticulum, "id_instance": mock_id_instance}
def test_rns_config_auto_creation(mock_rns, temp_dir):
"""Test that Reticulum config is created if it does not exist."""
config_dir = os.path.join(temp_dir, ".reticulum")
config_file = os.path.join(config_dir, "config")
# Ensure it doesn't exist
assert not os.path.exists(config_file)
with (
patch("meshchatx.meshchat.IdentityContext"),
patch("meshchatx.meshchat.WebAudioBridge"),
patch("meshchatx.meshchat.memory_log_handler"),
):
ReticulumMeshChat(
identity=mock_rns["id_instance"],
storage_dir=temp_dir,
reticulum_config_dir=config_dir,
)
# Method should have been called during init -> setup_identity
assert os.path.exists(config_file)
with open(config_file) as f:
content = f.read()
assert "[reticulum]" in content
assert "[interfaces]" in content
assert "enable_transport = False" in content
def test_rns_config_repair_if_invalid(mock_rns, temp_dir):
"""Test that Reticulum config is recreated if it is invalid/corrupt."""
config_dir = os.path.join(temp_dir, ".reticulum")
os.makedirs(config_dir, exist_ok=True)
config_file = os.path.join(config_dir, "config")
# Create a "corrupt" config
with open(config_file, "w") as f:
f.write("this is not a valid rns config")
with (
patch("meshchatx.meshchat.IdentityContext"),
patch("meshchatx.meshchat.WebAudioBridge"),
patch("meshchatx.meshchat.memory_log_handler"),
):
ReticulumMeshChat(
identity=mock_rns["id_instance"],
storage_dir=temp_dir,
reticulum_config_dir=config_dir,
)
with open(config_file) as f:
content = f.read()
# Should have been repaired
assert "[reticulum]" in content
assert "[interfaces]" in content
+40 -1
View File
@@ -1,8 +1,14 @@
import asyncio
from unittest.mock import MagicMock, patch
import numpy as np
import pytest
from meshchatx.src.backend.web_audio_bridge import WebAudioSink, WebAudioSource
from meshchatx.src.backend.web_audio_bridge import (
WebAudioBridge,
WebAudioSink,
WebAudioSource,
)
class _DummySink:
@@ -37,3 +43,36 @@ def test_web_audio_sink_encodes_and_sends_bytes():
loop.run_until_complete(asyncio.sleep(0.01))
loop.close()
assert sent, "expected audio bytes to be queued for sending"
@pytest.mark.asyncio
async def test_web_audio_bridge_lazy_loop():
"""Test that WebAudioBridge retrieves the loop lazily to avoid startup crashes."""
mock_tele_mgr = MagicMock()
mock_config_mgr = MagicMock()
# Mock get_event_loop to simulate it not being available during init
with patch("asyncio.get_event_loop", side_effect=RuntimeError("No running loop")):
bridge = WebAudioBridge(mock_tele_mgr, mock_config_mgr)
assert bridge._loop is None
# Simulate a running loop
current_loop = asyncio.get_running_loop()
assert bridge.loop == current_loop
assert bridge._loop == current_loop
def test_web_audio_bridge_asyncutils_fallback():
"""Test that WebAudioBridge falls back to AsyncUtils.main_loop if no loop is running."""
from meshchatx.src.backend.async_utils import AsyncUtils
mock_loop = MagicMock(spec=asyncio.AbstractEventLoop)
AsyncUtils.set_main_loop(mock_loop)
mock_tele_mgr = MagicMock()
mock_config_mgr = MagicMock()
with patch("asyncio.get_running_loop", side_effect=RuntimeError):
bridge = WebAudioBridge(mock_tele_mgr, mock_config_mgr)
assert bridge.loop == mock_loop
assert bridge._loop == mock_loop