feat(config): add configurable timeouts for web viewer integration

Introduced optional timeout settings in the configuration for various web viewer operations, including edge and node post timeouts, SQLite connection timeout, and requeue timeout. Updated the web viewer integration to utilize these settings, enhancing flexibility and reliability. Added commands to inspect the resolved configuration with sensitive keys redacted, and updated documentation accordingly.
This commit is contained in:
agessaman
2026-04-16 19:56:47 -07:00
parent 329905dd08
commit b5cce55604
13 changed files with 509 additions and 22 deletions
+11
View File
@@ -1392,6 +1392,17 @@ debug = false
# false: Start web viewer manually (recommended)
auto_start = false
# Optional timeout overrides for web viewer integration (seconds)
# edge_post_timeout_sec = 1.0
# node_post_timeout_sec = 0.5
# sqlite_connect_timeout_sec = 60.0
# requeue_put_timeout_sec = 60.0
# integration_shutdown_join_timeout_sec = 5.0
# viewer_stop_grace_timeout_sec = 5.0
# viewer_stop_force_timeout_sec = 2.0
# port_cleanup_lsof_timeout_sec = 5.0
# port_cleanup_kill_timeout_sec = 2.0
# Optional: database path for web viewer. If unset, the viewer uses [Bot] db_path (recommended).
# Only set this if you use a separate database for the viewer; you will see a startup warning.
# See docs/web-viewer.md for migrating from a separate database.
+7
View File
@@ -16,6 +16,13 @@ python validate_config.py [--config config.ini]
python meshcore_bot.py --validate-config [--config config.ini]
```
**Inspect resolved config** (redacted, then exit):
```bash
python meshcore_bot.py --show-config [--config config.ini]
python meshcore_bot.py --show-config-json [--config config.ini]
```
- **Exit 0** No errors (warnings and info may still be printed).
- **Exit 1** One or more errors; fix them before starting the bot.
+11
View File
@@ -39,6 +39,17 @@ Get meshcore-bot running on your machine in a few minutes.
python3 meshcore_bot.py
```
## Inspect effective config safely
Use these commands to inspect the resolved config with sensitive keys redacted:
```bash
python3 meshcore_bot.py --show-config --config config.ini
python3 meshcore_bot.py --show-config-json --config config.ini
```
Also available in the web UI at `/admin/config`.
## Production deployment
### Systemd service
+37
View File
@@ -6,9 +6,13 @@ Uses a modular structure for command creation and organization
import argparse
import asyncio
import configparser
import json
import signal
import sys
from modules.config_snapshot import config_to_redacted_sections, redacted_sections_to_ini_text
def _configure_unix_signal_handlers(loop, bot, shutdown_event: asyncio.Event) -> None:
"""Register Unix signal handlers for shutdown and config reload."""
@@ -55,9 +59,42 @@ def main():
action="store_true",
help="Validate config section names and exit before starting the bot (exit 1 on errors)",
)
parser.add_argument(
"--show-config",
action="store_true",
help="Print resolved config.ini with sensitive keys redacted and exit",
)
parser.add_argument(
"--show-config-json",
action="store_true",
help="Print resolved config.ini as redacted JSON and exit",
)
args = parser.parse_args()
if args.show_config and args.show_config_json:
print("Error: --show-config and --show-config-json are mutually exclusive", file=sys.stderr)
sys.exit(1)
if args.show_config or args.show_config_json:
cfg = configparser.ConfigParser()
try:
loaded_paths = cfg.read(args.config, encoding="utf-8")
except configparser.Error as exc:
print(f"Error: Invalid config file '{args.config}': {exc}", file=sys.stderr)
sys.exit(1)
if not loaded_paths:
print(f"Error: Config file not found: {args.config}", file=sys.stderr)
sys.exit(1)
sections = config_to_redacted_sections(cfg)
if args.show_config_json:
print(json.dumps(sections, indent=2, sort_keys=True))
else:
print(redacted_sections_to_ini_text(sections))
sys.exit(0)
if args.validate_config:
from modules.config_validation import (
SEVERITY_ERROR,
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env python3
"""
Status command
DM-only admin command that reports bot runtime status in a single response.
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from ..models import MeshMessage
from .base_command import BaseCommand
class StatusCommand(BaseCommand):
"""Report high-level runtime status for operators."""
name = "status"
keywords = ["status"]
description = "Show runtime status (DM only, admin only)"
requires_dm = True
cooldown_seconds = 2
category = "admin"
short_description = "Show runtime status for operators"
usage = "status"
examples = ["status", "!status"]
parameters: list[dict[str, str]] = []
def __init__(self, bot: Any):
super().__init__(bot)
self.status_enabled = self.get_config_value(
"Status_Command",
"enabled",
fallback=True,
value_type="bool",
)
def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool:
if not self.status_enabled:
return False
if not self.requires_admin_access():
return False
return super().can_execute(message, skip_channel_check=skip_channel_check)
def requires_admin_access(self) -> bool:
return True
async def execute(self, message: MeshMessage) -> bool:
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
connected = bool(getattr(self.bot, "connected", False))
radio_zombie = bool(getattr(self.bot, "is_radio_zombie", False))
radio_offline = bool(getattr(self.bot, "is_radio_offline", False))
web_running = False
integration = getattr(self.bot, "web_viewer_integration", None)
if integration is not None:
web_running = bool(getattr(integration, "running", False))
paused = not bool(getattr(self.bot, "channel_responses_enabled", True))
status_text = (
"Bot Status\n"
f"- time: {now}\n"
f"- connected: {connected}\n"
f"- radio_zombie: {radio_zombie}\n"
f"- radio_offline: {radio_offline}\n"
f"- channel_responses_paused: {paused}\n"
f"- web_viewer_running: {web_running}"
)
await self.send_response(message, status_text)
return True
+43
View File
@@ -0,0 +1,43 @@
"""Helpers for rendering resolved config snapshots with redaction."""
from __future__ import annotations
from configparser import ConfigParser
_REDACT_KEY_PARTS: tuple[str, ...] = (
"password",
"smtp_password",
"api_key",
"token",
"secret",
"smtp_user",
)
def is_sensitive_key(key: str) -> bool:
"""Return True when a config key should be redacted."""
key_lower = key.lower()
return any(part in key_lower for part in _REDACT_KEY_PARTS)
def config_to_redacted_sections(config: ConfigParser) -> dict[str, dict[str, str]]:
"""Return config sections as key/value maps with sensitive keys redacted."""
sections: dict[str, dict[str, str]] = {}
for section in config.sections():
sections[section] = {
key: "●●●●●●" if is_sensitive_key(key) else value
for key, value in config.items(section, raw=True)
}
return sections
def redacted_sections_to_ini_text(sections: dict[str, dict[str, str]]) -> str:
"""Render redacted config sections as human-readable INI text."""
lines: list[str] = []
for idx, (section_name, options) in enumerate(sections.items()):
if idx > 0:
lines.append("")
lines.append(f"[{section_name}]")
for key, value in options.items():
lines.append(f"{key} = {value}")
return "\n".join(lines)
+2 -7
View File
@@ -94,6 +94,7 @@ from modules.feed_manager import FeedManager
from modules.repeater_manager import RepeaterManager
from modules.url_shortener import _coerce_url_string
from modules.utils import calculate_distance, resolve_path
from modules.config_snapshot import config_to_redacted_sections
from modules.web_viewer.config_panels import CONFIG_PANELS, PANEL_CATEGORIES
from modules.web_viewer.integration import normalized_web_viewer_password
@@ -2028,13 +2029,7 @@ class BotDataViewer:
@self.app.route('/admin/config')
def admin_config():
"""Resolved config viewer — shows effective config.ini values with sensitive fields redacted."""
_REDACT_KEYS = {'password', 'smtp_password', 'api_key', 'token', 'secret', 'smtp_user'}
sections = {}
for section in self.config.sections():
sections[section] = {
k: '●●●●●●' if any(r in k for r in _REDACT_KEYS) else v
for k, v in self.config.items(section, raw=True)
}
sections = config_to_redacted_sections(self.config)
return render_template('admin_config.html', sections=sections, config_path=self.config_path)
@self.app.route('/mesh')
+73 -12
View File
@@ -54,6 +54,11 @@ class BotIntegration:
# Bounded packet_stream write queue: producers block up to this long before drop/retry.
_WRITE_QUEUE_PUT_TIMEOUT_SEC = 5.0
EDGE_POST_TIMEOUT_SEC = 1.0
NODE_POST_TIMEOUT_SEC = 0.5
SQLITE_CONNECT_TIMEOUT_SEC = 60.0
REQUEUE_PUT_TIMEOUT_SEC = 60.0
SHUTDOWN_JOIN_TIMEOUT_SEC = 5.0
def __init__(self, bot):
self.bot = bot
@@ -76,6 +81,7 @@ class BotIntegration:
self._write_queue: queue.Queue = queue.Queue(maxsize=self._write_queue_maxsize)
self._drain_stop = threading.Event()
self._drain_thread: Optional[threading.Thread] = None
self._load_timeouts_from_config()
# Initialize HTTP session with connection pooling for efficient reuse
self._init_http_session()
# Generate a shared secret for authenticating internal /api/stream_data calls.
@@ -90,6 +96,33 @@ class BotIntegration:
# Start background drain thread after table is confirmed to exist
self._start_drain_thread()
def _get_float_config(self, key: str, fallback: float) -> float:
"""Read float from [Web_Viewer] config with sane fallback."""
try:
value = self.bot.config.getfloat("Web_Viewer", key, fallback=fallback)
return value if value > 0 else fallback
except (ValueError, TypeError, OSError):
return fallback
def _load_timeouts_from_config(self) -> None:
"""Load optional BotIntegration timeout settings from config."""
self.edge_post_timeout_sec = self._get_float_config(
"edge_post_timeout_sec", self.EDGE_POST_TIMEOUT_SEC
)
self.node_post_timeout_sec = self._get_float_config(
"node_post_timeout_sec", self.NODE_POST_TIMEOUT_SEC
)
self.sqlite_connect_timeout_sec = self._get_float_config(
"sqlite_connect_timeout_sec", self.SQLITE_CONNECT_TIMEOUT_SEC
)
self.requeue_put_timeout_sec = self._get_float_config(
"requeue_put_timeout_sec", self.REQUEUE_PUT_TIMEOUT_SEC
)
self.shutdown_join_timeout_sec = self._get_float_config(
"integration_shutdown_join_timeout_sec",
self.SHUTDOWN_JOIN_TIMEOUT_SEC,
)
def _init_http_session(self):
"""Initialize a requests.Session with connection pooling and keep-alive"""
try:
@@ -188,7 +221,7 @@ class BotIntegration:
import sqlite3
db_path = self._get_web_viewer_db_path()
with closing(sqlite3.connect(str(db_path), timeout=60.0)) as conn:
with closing(sqlite3.connect(str(db_path), timeout=self.sqlite_connect_timeout_sec)) as conn:
cur = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='packet_stream'"
)
@@ -247,7 +280,7 @@ class BotIntegration:
"""Restore rows to the queue after a failed flush (FIFO). Logs if the queue stays full."""
for i, row in enumerate(rows):
try:
self._write_queue.put(row, timeout=60.0)
self._write_queue.put(row, timeout=self.requeue_put_timeout_sec)
except queue.Full:
remaining = len(rows) - i
self.bot.logger.error(
@@ -275,7 +308,7 @@ class BotIntegration:
max_retries = 3
for attempt in range(max_retries):
try:
with closing(sqlite3.connect(str(db_path), timeout=60.0)) as conn:
with closing(sqlite3.connect(str(db_path), timeout=self.sqlite_connect_timeout_sec)) as conn:
conn.executemany(
'INSERT INTO packet_stream (timestamp, data, type) VALUES (?, ?, ?)',
rows,
@@ -451,7 +484,7 @@ class BotIntegration:
cutoff_time = time.time() - (days_to_keep * 24 * 60 * 60)
db_path = self._get_web_viewer_db_path()
with closing(sqlite3.connect(str(db_path), timeout=60.0)) as conn:
with closing(sqlite3.connect(str(db_path), timeout=self.sqlite_connect_timeout_sec)) as conn:
cursor = conn.cursor()
# Clean up old packet stream data
@@ -513,14 +546,14 @@ class BotIntegration:
}
if self.http_session:
try:
self.http_session.post(url, json=payload, timeout=1.0)
self.http_session.post(url, json=payload, timeout=self.edge_post_timeout_sec)
self._record_web_viewer_result(True)
except Exception:
self._record_web_viewer_result(False)
else:
import requests
try:
requests.post(url, json=payload, timeout=1.0, headers=headers)
requests.post(url, json=payload, timeout=self.edge_post_timeout_sec, headers=headers)
self._record_web_viewer_result(True)
except Exception:
self._record_web_viewer_result(False)
@@ -548,7 +581,7 @@ class BotIntegration:
'X-Requested-With': 'BotIntegration',
}
try:
requests.post(url, json=payload, timeout=0.5, headers=headers)
requests.post(url, json=payload, timeout=self.node_post_timeout_sec, headers=headers)
self._record_web_viewer_result(True)
except Exception:
self._record_web_viewer_result(False)
@@ -561,7 +594,7 @@ class BotIntegration:
# Stop drain thread and do a final flush of any queued rows
self._drain_stop.set()
if self._drain_thread and self._drain_thread.is_alive():
self._drain_thread.join(timeout=5.0)
self._drain_thread.join(timeout=self.shutdown_join_timeout_sec)
self._flush_write_queue()
# Close HTTP session to clean up connections
if hasattr(self, 'http_session') and self.http_session:
@@ -573,6 +606,10 @@ class WebViewerIntegration:
# Whitelist of allowed host bindings for security
ALLOWED_HOSTS = ['127.0.0.1', 'localhost', '0.0.0.0']
VIEWER_STOP_GRACE_TIMEOUT_SEC = 5.0
VIEWER_STOP_FORCE_TIMEOUT_SEC = 2.0
PORT_CLEANUP_LSOF_TIMEOUT_SEC = 5.0
PORT_CLEANUP_KILL_TIMEOUT_SEC = 2.0
def __init__(self, bot):
self.bot = bot
@@ -591,6 +628,22 @@ class WebViewerIntegration:
self.port = bot.config.getint('Web_Viewer', 'port', fallback=8080) # Web viewer uses 8080
self.debug = bot.config.getboolean('Web_Viewer', 'debug', fallback=False)
self.auto_start = bot.config.getboolean('Web_Viewer', 'auto_start', fallback=False)
self.viewer_stop_grace_timeout_sec = self._get_float_config(
"viewer_stop_grace_timeout_sec",
self.VIEWER_STOP_GRACE_TIMEOUT_SEC,
)
self.viewer_stop_force_timeout_sec = self._get_float_config(
"viewer_stop_force_timeout_sec",
self.VIEWER_STOP_FORCE_TIMEOUT_SEC,
)
self.port_cleanup_lsof_timeout_sec = self._get_float_config(
"port_cleanup_lsof_timeout_sec",
self.PORT_CLEANUP_LSOF_TIMEOUT_SEC,
)
self.port_cleanup_kill_timeout_sec = self._get_float_config(
"port_cleanup_kill_timeout_sec",
self.PORT_CLEANUP_KILL_TIMEOUT_SEC,
)
# Validate configuration for security
self._validate_config()
@@ -608,6 +661,14 @@ class WebViewerIntegration:
if self.enabled and self.auto_start:
self.start_viewer()
def _get_float_config(self, key: str, fallback: float) -> float:
"""Read float timeout from [Web_Viewer] with positive-value guard."""
try:
value = self.bot.config.getfloat("Web_Viewer", key, fallback=fallback)
return value if value > 0 else fallback
except (ValueError, TypeError, OSError):
return fallback
def _validate_config(self):
"""Validate web viewer configuration for security"""
# Validate host against whitelist
@@ -667,13 +728,13 @@ class WebViewerIntegration:
try:
# First try graceful termination
self.viewer_process.terminate()
self.viewer_process.wait(timeout=5)
self.viewer_process.wait(timeout=self.viewer_stop_grace_timeout_sec)
self.logger.info("Web viewer stopped gracefully")
except subprocess.TimeoutExpired:
self.logger.warning("Web viewer did not stop gracefully, forcing termination")
try:
self.viewer_process.kill()
self.viewer_process.wait(timeout=2)
self.viewer_process.wait(timeout=self.viewer_stop_force_timeout_sec)
except subprocess.TimeoutExpired:
self.logger.error("Failed to kill web viewer process")
except Exception as e:
@@ -706,7 +767,7 @@ class WebViewerIntegration:
# Additional cleanup: kill any remaining processes on the port
try:
result = subprocess.run(['lsof', '-ti', f':{self.port}'],
capture_output=True, text=True, timeout=5)
capture_output=True, text=True, timeout=self.port_cleanup_lsof_timeout_sec)
if result.returncode == 0 and result.stdout.strip():
pids = result.stdout.strip().split('\n')
for pid in pids:
@@ -726,7 +787,7 @@ class WebViewerIntegration:
self.logger.warning(f"Refusing to kill system PID: {pid}")
continue
subprocess.run(['kill', '-9', str(pid_int)], timeout=2)
subprocess.run(['kill', '-9', str(pid_int)], timeout=self.port_cleanup_kill_timeout_sec)
self.logger.info(f"Killed remaining process {pid} on port {self.port}")
except (ValueError, subprocess.TimeoutExpired) as e:
self.logger.warning(f"Failed to kill process {pid}: {e}")
+2 -2
View File
@@ -5,8 +5,8 @@ build-backend = "setuptools.build_meta"
[project]
name = "meshcore-bot"
version = "0.1.0"
description = "MeshCore Bot using the meshcore-cli and meshcore.py packages"
version = "0.9.0"
description = "MeshCore Bot with commands for mesh testing, utilities, and service integration."
readme = "README.md"
requires-python = ">=3.9"
dependencies = [
+101
View File
@@ -0,0 +1,101 @@
"""Tests for meshcore_bot.py CLI config-inspection flags."""
from __future__ import annotations
import json
import sys
from pathlib import Path
import pytest
from meshcore_bot import main
def _write_config(path: Path) -> None:
path.write_text(
"""[Connection]
connection_type = serial
serial_port = /dev/ttyUSB0
[Bot]
db_path = /tmp/bot.db
api_token = super-secret-token
[Notifications]
smtp_user = alerts@example.com
smtp_password = hunter2
recipient = ops@example.com
""",
encoding="utf-8",
)
def test_show_config_prints_redacted_ini(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys) -> None:
config_path = tmp_path / "config.ini"
_write_config(config_path)
monkeypatch.setattr(sys, "argv", ["meshcore_bot.py", "--show-config", "--config", str(config_path)])
with pytest.raises(SystemExit) as exc:
main()
assert exc.value.code == 0
out = capsys.readouterr().out
assert "[Bot]" in out
assert "db_path = /tmp/bot.db" in out
assert "api_token = ●●●●●●" in out
assert "smtp_user = ●●●●●●" in out
assert "smtp_password = ●●●●●●" in out
def test_show_config_json_prints_redacted_json(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
config_path = tmp_path / "config.ini"
_write_config(config_path)
monkeypatch.setattr(
sys, "argv", ["meshcore_bot.py", "--show-config-json", "--config", str(config_path)]
)
with pytest.raises(SystemExit) as exc:
main()
assert exc.value.code == 0
payload = json.loads(capsys.readouterr().out)
assert payload["Bot"]["db_path"] == "/tmp/bot.db"
assert payload["Bot"]["api_token"] == "●●●●●●"
assert payload["Notifications"]["smtp_password"] == "●●●●●●"
def test_show_config_missing_file_exits_1(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
missing_path = tmp_path / "missing.ini"
monkeypatch.setattr(sys, "argv", ["meshcore_bot.py", "--show-config", "--config", str(missing_path)])
with pytest.raises(SystemExit) as exc:
main()
assert exc.value.code == 1
err = capsys.readouterr().err
assert "Config file not found" in err
def test_show_config_invalid_ini_exits_1(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
bad_path = tmp_path / "bad.ini"
bad_path.write_text("[Connection\nserial_port=/dev/ttyUSB0\n", encoding="utf-8")
monkeypatch.setattr(sys, "argv", ["meshcore_bot.py", "--show-config-json", "--config", str(bad_path)])
with pytest.raises(SystemExit) as exc:
main()
assert exc.value.code == 1
err = capsys.readouterr().err
assert "Invalid config file" in err
+88
View File
@@ -0,0 +1,88 @@
"""Tests for modules.commands.status_command."""
import asyncio
import configparser
from unittest.mock import AsyncMock, MagicMock, Mock
from modules.commands.status_command import StatusCommand
from tests.conftest import mock_message
def _make_bot(*, enabled=True):
bot = MagicMock()
bot.logger = Mock()
bot.connected = True
bot.is_radio_zombie = False
bot.is_radio_offline = False
bot.channel_responses_enabled = True
bot.web_viewer_integration = MagicMock()
bot.web_viewer_integration.running = True
config = configparser.ConfigParser()
config.add_section("Bot")
config.set("Bot", "bot_name", "TestBot")
config.add_section("Channels")
config.set("Channels", "monitor_channels", "general")
config.add_section("Keywords")
config.add_section("Status_Command")
config.set("Status_Command", "enabled", "true" if enabled else "false")
config.add_section("Admin_ACL")
config.set("Admin_ACL", "admin_pubkeys", "a" * 64)
config.set("Admin_ACL", "admin_commands", "status")
bot.config = config
bot.translator = MagicMock()
bot.translator.translate = Mock(side_effect=lambda key, **kw: key)
bot.translator.get_value = Mock(return_value=None)
bot.command_manager = MagicMock()
bot.command_manager.monitor_channels = ["general"]
bot.command_manager.send_response = AsyncMock(return_value=True)
return bot
def _run(coro):
return asyncio.run(coro)
class TestStatusCommandPermissions:
def test_dm_admin_allowed(self):
cmd = StatusCommand(_make_bot(enabled=True))
msg = mock_message(content="status", is_dm=True, sender_id="user1")
msg.sender_pubkey = "a" * 64
assert cmd.can_execute(msg) is True
def test_channel_disallowed(self):
cmd = StatusCommand(_make_bot(enabled=True))
msg = mock_message(content="status", is_dm=False, sender_id="user1")
msg.sender_pubkey = "a" * 64
assert cmd.can_execute(msg) is False
def test_non_admin_disallowed(self):
cmd = StatusCommand(_make_bot(enabled=True))
msg = mock_message(content="status", is_dm=True, sender_id="user1")
msg.sender_pubkey = "b" * 64
assert cmd.can_execute(msg) is False
def test_disabled_disallowed(self):
cmd = StatusCommand(_make_bot(enabled=False))
msg = mock_message(content="status", is_dm=True, sender_id="user1")
msg.sender_pubkey = "a" * 64
assert cmd.can_execute(msg) is False
class TestStatusCommandExecute:
def test_execute_sends_single_status_message(self):
bot = _make_bot(enabled=True)
cmd = StatusCommand(bot)
msg = mock_message(content="status", is_dm=True, sender_id="user1")
msg.sender_pubkey = "a" * 64
result = _run(cmd.execute(msg))
assert result is True
bot.command_manager.send_response.assert_called_once()
text = bot.command_manager.send_response.call_args[0][1]
assert "Bot Status" in text
assert "connected: True" in text
assert "radio_zombie: False" in text
assert "web_viewer_running: True" in text
+25
View File
@@ -13,6 +13,31 @@ import pytest
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def cleanup_sqlite_connections(monkeypatch):
"""Track and close SQLite connections opened during each test.
Some app code paths intentionally create ad-hoc connections for request-style
operations; this fixture ensures any leaked handles are closed so Python 3.13
ResourceWarning checks stay clean.
"""
tracked_connections = []
original_connect = sqlite3.connect
def _tracked_connect(*args, **kwargs):
conn = original_connect(*args, **kwargs)
tracked_connections.append(conn)
return conn
monkeypatch.setattr(sqlite3, "connect", _tracked_connect)
yield
for conn in tracked_connections:
try:
conn.close()
except sqlite3.Error:
pass
@pytest.fixture
def viewer_with_db(tmp_path):
"""Create a BotDataViewer instance with a test database.
+36 -1
View File
@@ -211,7 +211,7 @@ class TestInsertPacketStreamRow:
def test_queue_exception_logged(self):
bi = _make_bot_integration()
bi._write_queue = Mock()
bi._write_queue.put_nowait.side_effect = Exception("full")
bi._write_queue.put.side_effect = Exception("full")
# Should not raise
bi._insert_packet_stream_row("{}", "packet")
bi.bot.logger.warning.assert_called_once()
@@ -491,3 +491,38 @@ class TestShutdown:
bi._drain_thread.is_alive.return_value = True
bi.shutdown()
bi._drain_thread.join.assert_called_once()
class TestIntegrationTimeoutConfig:
def test_bot_integration_loads_custom_timeouts(self):
bot = _make_bot()
bot.config.set("Web_Viewer", "edge_post_timeout_sec", "2.5")
bot.config.set("Web_Viewer", "node_post_timeout_sec", "1.25")
bot.config.set("Web_Viewer", "sqlite_connect_timeout_sec", "42")
bot.config.set("Web_Viewer", "requeue_put_timeout_sec", "7")
bot.config.set("Web_Viewer", "integration_shutdown_join_timeout_sec", "3")
bi = _make_bot_integration(bot)
assert bi.edge_post_timeout_sec == 2.5
assert bi.node_post_timeout_sec == 1.25
assert bi.sqlite_connect_timeout_sec == 42
assert bi.requeue_put_timeout_sec == 7
assert bi.shutdown_join_timeout_sec == 3
def test_web_viewer_integration_loads_custom_timeouts(self):
from modules.web_viewer.integration import WebViewerIntegration
bot = _make_bot()
bot.config.set("Web_Viewer", "viewer_stop_grace_timeout_sec", "9")
bot.config.set("Web_Viewer", "viewer_stop_force_timeout_sec", "4")
bot.config.set("Web_Viewer", "port_cleanup_lsof_timeout_sec", "8")
bot.config.set("Web_Viewer", "port_cleanup_kill_timeout_sec", "1")
with patch("modules.web_viewer.integration.BotIntegration._init_http_session"), \
patch("modules.web_viewer.integration.BotIntegration._init_packet_stream_table"), \
patch("modules.web_viewer.integration.BotIntegration._start_drain_thread"):
wvi = WebViewerIntegration(bot)
assert wvi.viewer_stop_grace_timeout_sec == 9
assert wvi.viewer_stop_force_timeout_sec == 4
assert wvi.port_cleanup_lsof_timeout_sec == 8
assert wvi.port_cleanup_kill_timeout_sec == 1