- Fix logfiles_router and thread_registry to resolve .rpt log files from Path(server["exe_path"]).parent/server/ instead of the languard data dir, which never contained log files — log list and live tail both now work correctly - Rewrite get_ui_schema() in config_generator to cover all ~80 fields across all 5 sections (server/basic/profile/launch/rcon) with proper toggle/select/number/password/tag-list/hidden widgets and labels; missions field is hidden (managed by Missions tab) - Add formatSelectDisplay() to ConfigEditor so select fields show descriptive text (e.g. "0 - Never") instead of raw numbers in view mode - Add ToggleDisplay for boolean fields (Enabled/Disabled with indicator dot) - Add section tab labels and descriptions to ConfigEditor - Add MissionList UX hints and dynamic Add/In Rotation button labels - Add "hidden" to FieldSchema widget union type - Update API.md, ARCHITECTURE.md, CLAUDE.md, FRONTEND.md, MODULES.md, THREADING.md to document log path fix and schema coverage
7.8 KiB
Threading & Concurrency Model
Overview
Languard uses a hybrid concurrency model:
- FastAPI (asyncio) handles HTTP requests and WebSocket connections on the main event loop
- Python
threading.Threadhandles long-running background work per server queue.Queuebridges the thread world to the asyncio world for WebSocket broadcasting- SQLAlchemy sync sessions with thread-local connections provide thread-safe database access
Thread Architecture
For N running servers, the system runs up to 4N+1 background threads:
| Thread Type | Count | Purpose |
|---|---|---|
BroadcastThread |
1 (global) | Bridges queue.Queue to asyncio WebSocket broadcasts |
LogTailThread |
1 per server | Tails .rpt log files, parses lines, persists to DB, broadcasts events |
ProcessMonitorThread |
1 per server | Monitors server process, detects crashes, triggers auto-restart |
MetricsCollectorThread |
1 per server | Collects CPU/RAM metrics via psutil every 10 seconds |
RemoteAdminPollerThread |
1 per server | Polls player list via RCon, syncs join/leave events |
All server-specific threads are managed by ThreadRegistry, which creates/destroys thread bundles as servers start/stop.
BaseServerThread
All background threads extend BaseServerThread, which provides:
- Stop event:
threading.Eventfor graceful shutdown - Thread-local DB: Creates a fresh SQLAlchemy connection per thread via
get_thread_db() - Exception backoff: On unhandled exceptions, sleeps with exponential backoff (5s → 30s max), then retries. If stop event is set, exits cleanly.
- Abstract
run_loop()method: Subclasses implement the main loop, called repeatedly until stop event is set
class BaseServerThread(threading.Thread):
def __init__(self, server_id: int, ...):
super().__init__(daemon=True)
self.server_id = server_id
self._stop_event = threading.Event()
def stop(self):
self._stop_event.set()
def run(self):
while not self._stop_event.is_set():
try:
self.run_loop()
except Exception:
backoff = min(backoff * 2, 30)
self._stop_event.wait(backoff)
ThreadRegistry
ThreadRegistry manages thread lifecycle per server:
start_server_threads(server_id, db)— Creates and starts all 4 thread types for a serverstop_server_threads(server_id)— Sets stop events and joins all threads for a serverreattach_server_threads(server_id, db)— Recovers threads for a server that survived a process restartstop_all()— Stops all threads for all servers (called on shutdown)
Thread bundles are stored in a dict: {server_id → ThreadBundle}, where ThreadBundle is a dataclass holding all thread references.
BroadcastThread
The BroadcastThread is the single global thread that bridges synchronous background threads to asynchronous WebSocket clients:
- Background threads push events into a
queue.Queue(maxsize=1000) BroadcastThreadruns a loop reading from the queue- For each event, it calls
asyncio.run_coroutine_threadsafe()to schedule a WebSocket broadcast on the main event loop - If the queue is full, events are dropped (non-blocking put)
Events are broadcast to WebSocket clients subscribed to the relevant server_id (or None for all servers).
ProcessManager
ProcessManager is a singleton that manages server processes via subprocess.Popen:
start_process(server_id, cmd, cwd, env)— Starts a new subprocess, stores the PIDstop_process(server_id, timeout)— Sends terminate signal, waits for exit, force-kills after timeoutkill_process(server_id)— Force-kills the process immediatelyrecover_on_startup(db)— On startup, checks all stored PIDs against running processes viapsutil.pid_exists(). If a process is still alive, marks the server as running. If not, marks it as stopped.- Thread-safe with per-server
threading.Lock
LogTailThread
Tails the Arma 3 .rpt log file for each server:
- Resolves the latest log file path using
Path(server["exe_path"]).parent / "server"— Arma 3 writes .rpt files next to its executable, not in the languard server data directory - Reads new lines from the end of the file, detecting log rotation (Windows/NTFS safe)
- Parses each line using
RPTParser.parse_line()to extract timestamp, level, and message - Persists parsed entries to the
logstable viaLogRepository - Broadcasts
logevents via the global queue
ProcessMonitorThread
Monitors each server process for crashes:
- Checks every 5 seconds whether the process is still alive
- If the process has exited unexpectedly:
- Updates server status to
crashed - Logs the crash event
- If
auto_restartis enabled and restart count hasn't exceededmax_restartswithin therestart_window_seconds:- Triggers a restart via
ServerService.start_server() - Increments
restart_count
- Triggers a restart via
- Updates server status to
MetricsCollectorThread
Collects CPU and RAM metrics for each running server:
- Uses
psutil.Process(pid)to get CPU and memory usage - Collects every 10 seconds
- Stores metrics in the
metricstable viaMetricsRepository - Broadcasts
metricsevents via the global queue
RemoteAdminPollerThread
Polls the BattlEye RCon interface for player list updates:
- Connects via
Arma3RemoteAdminusingBERConClient - Polls player list every 10 seconds
- Compares current players with previous state to detect joins/leaves
- On player join: upserts to
playerstable, inserts toplayer_history, broadcastsplayersevent - On player leave: removes from
players, updatesleft_atinplayer_history, broadcastsplayersevent - On RCon connection failure: reconnects with exponential backoff
WebSocketManager
Runs on the main asyncio event loop:
- Clients connect to
/ws?token=JWT&server_id=N - JWT is validated on connection; invalid tokens close with code 4001
- Clients subscribe to specific
server_ids orNone(all servers) broadcast(server_id, message)sends JSON-encoded messages to matching subscribersdisconnect(websocket)removes the client from the registry- Thread-safe via
asyncio.Lock
Thread Safety Rules
- Database access: Each thread uses its own connection via
get_thread_db(). No shared DB connections. - WebSocket broadcasting: Threads write to
queue.Queue, which is thread-safe. OnlyBroadcastThreadreads from the queue. - Process management:
ProcessManageruses per-server locks for thread-safe start/stop operations. - SQLite WAL mode: Enables concurrent reads from multiple threads while a single writer operates.
- Asyncio locks:
WebSocketManagerusesasyncio.Lockfor connection registry modifications.
Scheduled Jobs
APScheduler BackgroundScheduler runs 3 cleanup cron jobs:
| Job | Schedule | Cleanup |
|---|---|---|
| Clean up old log entries | Daily at 03:00 | DELETE FROM logs WHERE created_at < datetime('now', '-7 days') |
| Clean up old metrics | Every 6 hours | DELETE FROM metrics WHERE timestamp < datetime('now', '-1 day') |
| Clean up old events | Weekly (Sunday 04:00) | DELETE FROM server_events WHERE created_at < datetime('now', '-30 days') |
Startup Sequence
- Init DB engine and run pending migrations
- Register built-in adapters (Arma 3) and scan for third-party plugins
- Create
WebSocketManager(asyncio-only) - Create global
BroadcastThread(queue → asyncio bridge) - Create
ThreadRegistrywithProcessManagerand adapter registry - Recover processes that survived a restart (PID validation via psutil)
- Re-attach monitoring threads for running servers
- Seed default admin user if no users exist
- Register and start APScheduler cleanup jobs
Shutdown Sequence
- Stop all server threads via
ThreadRegistry.stop_all() - Stop
BroadcastThreadand join with 5s timeout - Stop APScheduler