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 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 welcome import clean_stale_dm_messages
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}...")
try:
from mcrcon import MCRcon
def _rcon():
with MCRcon(MINECRAFT_RCON_HOST, MINECRAFT_RCON_PASSWORD, port=MINECRAFT_RCON_PORT, timeout=3) as mcr:
return mcr.command(f"whitelist add {username}")
return rcon_command(
MINECRAFT_RCON_HOST,
MINECRAFT_RCON_PASSWORD,
f"whitelist add {username}",
port=MINECRAFT_RCON_PORT,
timeout=RCON_TIMEOUT,
)
loop = asyncio.get_running_loop()
response = await asyncio.wait_for(loop.run_in_executor(None, _rcon), timeout=RCON_TIMEOUT)
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 = (
f"<strong>Minecraft</strong><br>"
f"You have been whitelisted on the SMP!<br>"
f"Server: <strong>minecraft.lotusguild.org</strong><br>"
f"Username: <strong>{username}</strong>"
f'<font color="#980000"><strong>Minecraft — Lotus SMP ✿</strong></font><br>'
f"<blockquote>"
f"{status_line}<br>"
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)
except ImportError:
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:
except (asyncio.TimeoutError, RconError, OSError) as e:
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).")
-1
View File
@@ -2,4 +2,3 @@ matrix-nio[e2e]
python-dotenv>=1.2.2
aiohttp
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 = "".join(char for char in text if char.isprintable())
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