fix(matrixbot): replace mcrcon with a thread-safe RCON client

mcrcon implements its read timeout with signal.SIGALRM, which only works
on the main thread. The Minecraft commands call it from a worker thread
via loop.run_in_executor, so it raised "signal only works in main thread
of the main interpreter" on every invocation. The replacement uses
socket.settimeout(), which has no such restriction.

This code was already deployed on LXC 151 but had never been committed,
so any matrixbot deploy would have reverted it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-16 01:37:38 -04:00
co-authored by Claude Opus 5
parent 5d420e318c
commit 7c05827dbc
3 changed files with 73 additions and 16 deletions
+26 -15
View File
@@ -13,7 +13,7 @@ import aiohttp
from nio import AsyncClient from nio import AsyncClient
from utils import send_text, send_html, send_reaction, edit_html, sanitize_input from utils import send_text, send_html, send_reaction, edit_html, sanitize_input, rcon_command, RconError
from wordle import handle_wordle, wordle_stats as _wordle_stats from wordle import handle_wordle, wordle_stats as _wordle_stats
from welcome import clean_stale_dm_messages from welcome import clean_stale_dm_messages
from config import ( from config import (
@@ -1317,29 +1317,40 @@ async def cmd_minecraft(client: AsyncClient, room_id: str, sender: str, args: st
await send_text(client, room_id, f"Whitelisting {username}...") await send_text(client, room_id, f"Whitelisting {username}...")
try: try:
from mcrcon import MCRcon
def _rcon(): def _rcon():
with MCRcon(MINECRAFT_RCON_HOST, MINECRAFT_RCON_PASSWORD, port=MINECRAFT_RCON_PORT, timeout=3) as mcr: return rcon_command(
return mcr.command(f"whitelist add {username}") MINECRAFT_RCON_HOST,
MINECRAFT_RCON_PASSWORD,
f"whitelist add {username}",
port=MINECRAFT_RCON_PORT,
timeout=RCON_TIMEOUT,
)
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
response = await asyncio.wait_for(loop.run_in_executor(None, _rcon), timeout=RCON_TIMEOUT) response = await asyncio.wait_for(loop.run_in_executor(None, _rcon), timeout=RCON_TIMEOUT)
logger.info(f"RCON response: {response}") logger.info(f"RCON response: {response}")
plain = f"Minecraft\nYou have been whitelisted on the SMP!\nServer: minecraft.lotusguild.org\nUsername: {username}" already = "already whitelisted" in response.lower()
status_line = (
f"↺ <strong>{username}</strong> was already whitelisted."
if already
else f"✅ <strong>{username}</strong> is whitelisted and ready to join."
)
plain = (
f"✿ Minecraft — Lotus SMP ✿\n"
f"{'Already whitelisted' if already else 'Whitelisted'}: {username}\n"
f"Server: minecraft.lotusguild.org"
)
html = ( html = (
f"<strong>Minecraft</strong><br>" f'<font color="#980000"><strong>Minecraft — Lotus SMP ✿</strong></font><br>'
f"You have been whitelisted on the SMP!<br>" f"<blockquote>"
f"Server: <strong>minecraft.lotusguild.org</strong><br>" f"{status_line}<br>"
f"Username: <strong>{username}</strong>" f'<strong>Server:</strong> <code>minecraft.lotusguild.org</code><br>'
f"<sup><em>fresh world · hard mode · no takebacks</em></sup>"
f"</blockquote>"
) )
await send_html(client, room_id, plain, html) await send_html(client, room_id, plain, html)
except ImportError: except (asyncio.TimeoutError, RconError, OSError) as e:
await send_text(client, room_id, "mcrcon is not installed. Ask an admin to install it.")
except asyncio.TimeoutError:
await send_text(client, room_id, "Minecraft server timed out. It may be offline.")
except Exception as e:
logger.error(f"RCON error: {e}", exc_info=True) logger.error(f"RCON error: {e}", exc_info=True)
await send_text(client, room_id, "Failed to whitelist. The server may be offline (let jared know).") await send_text(client, room_id, "Failed to whitelist. The server may be offline (let jared know).")
-1
View File
@@ -2,4 +2,3 @@ matrix-nio[e2e]
python-dotenv>=1.2.2 python-dotenv>=1.2.2
aiohttp aiohttp
markdown markdown
mcrcon
+47
View File
@@ -155,3 +155,50 @@ def sanitize_input(text: str, max_length: int = MAX_INPUT_LENGTH) -> str:
text = text.strip()[:max_length] text = text.strip()[:max_length]
text = "".join(char for char in text if char.isprintable()) text = "".join(char for char in text if char.isprintable())
return text return text
class RconError(Exception):
pass
def rcon_command(host: str, password: str, command: str, port: int = 25575, timeout: float = 5.0) -> str:
"""Minimal Source RCON client (Minecraft protocol) using socket timeouts.
Deliberately avoids the `mcrcon` package: it implements its read timeout
via signal.SIGALRM, which only works on the main thread — this is always
called from a worker thread (via loop.run_in_executor), so that library
raises "signal only works in main thread of the main interpreter" every
time. socket.settimeout() has no such restriction.
"""
import socket
import struct
def send_packet(sock, pkt_id, pkt_type, payload):
body = struct.pack("<ii", pkt_id, pkt_type) + payload.encode("utf-8") + b"\x00\x00"
sock.sendall(struct.pack("<i", len(body)) + body)
def read_packet(sock):
def recv_exact(n):
buf = b""
while len(buf) < n:
chunk = sock.recv(n - len(buf))
if not chunk:
raise RconError("Connection closed by server")
buf += chunk
return buf
length = struct.unpack("<i", recv_exact(4))[0]
data = recv_exact(length)
pkt_id, _pkt_type = struct.unpack("<ii", data[:8])
return pkt_id, data[8:-2].decode("utf-8", "replace")
with socket.create_connection((host, port), timeout=timeout) as sock:
sock.settimeout(timeout)
send_packet(sock, 1, 3, password)
auth_id, _ = read_packet(sock)
if auth_id == -1:
raise RconError("RCON authentication failed (wrong password)")
send_packet(sock, 2, 2, command)
_, response = read_packet(sock)
return response