feat(api): add pooled RCON client and game event bus - #22
Conversation
The existing roadmaps (ROADMAP.md, TASKS.md, MINECRAFT_GAMEPLAY_ENHANCEMENTS.md) all plan the server management product: more endpoints, more admin pages, more ops polish. None of them plan the experience the people actually playing on the server see. This adds a roadmap for that, organised by feasibility on the Pi 5 hardware rather than by release. It covers four foundations that most other ideas depend on, then tiers of gameplay and integration features, with a suggested build order and the Pi-specific gotchas that would otherwise be discovered the hard way (the RP1 GPIO change, NeoPixel drivers, the 1.20.5 item component migration, SD-card write endurance). It also lists the known issues sitting in the path of that work, each verified against the code rather than assumed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phase 0 of the family server roadmap. Two pieces of plumbing that most of the planned gameplay features depend on. api/rcon.py — an in-process RCON client Every command previously shelled out to scripts/rcon-client.sh, which opened a new TCP connection and re-authenticated for each one. That is roughly 50-100ms of overhead per command and makes command batches impractical. The script's Python fallback also had two defects. It did a single unframed sock.recv(4096), so responses longer than that were truncated or split across reads, and it treated any reply of four or more bytes as a successful login rather than checking for the -1 request id the protocol uses to signal auth failure. The new client holds one authenticated connection open behind a lock, reassembles the multi-packet responses Minecraft sends for output over 4096 bytes, reconnects transparently when the container restarts underneath it, and rejects commands long enough that the server would silently truncate them. api/server.py reaches it through run_rcon_command(), which falls back to the shell script when RCON is unconfigured or unreachable, since that script can also reach rcon-cli inside the container when the port is not published to the host. scripts/rcon-client.sh is unchanged for CLI use. api/events.py — a game event bus The server log is the only real-time signal Minecraft produces. It was followed purely to stream raw text to the browser, so anything reactive had to re-parse raw lines for itself, and nothing was captured at all unless someone had the dashboard open. Lines are now parsed into typed events (chat, connect, join, leave, death, advancement, command, server_ready, server_stopping), appended to data/events/YYYY-MM-DD.jsonl, and dispatched to registered handlers. Writes are batched and files older than 30 days are pruned, to limit SD-card wear on the Pi. A handler that raises is logged and skipped rather than stopping the bus. The follower now starts with the API rather than on the first browser connection, and no longer stops when the last client disconnects. Events that happened with the dashboard closed were previously lost. stop_log_reader() brings it down deliberately; the log streaming tests were updated from the old contract to this one. Connect and join are separate event types on purpose. A single player session logs two lines, one carrying the network address and one saying they joined, so treating both as a join double-counts every session. Also fixes: the WebSocket execute_command handler reached RCON without sanitising its input, bypassing the command allowlist that POST /api/server/command enforces. It now runs the same validation and writes the same audit entries. Adds GET /api/events and GET /api/events/types (both logs.view), a game_event WebSocket message alongside the existing raw logs stream, getEvents() and getEventTypes() in web/src/services/api.js, the OpenAPI paths and schema, and docs/EVENT_BUS.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Dependency Review✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.Scanned FilesNone |
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Critical command-retry and event-persistence issues, along with moderate correctness gaps, remain unresolved.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 4
Open (6)
Retrying after lost responses can duplicate commands · New 502 fallback can duplicate side-effecting commands · New Follower replays backlog events on every restart · New Non-string commands crash before returning command_error · New Stopping the log reader cannot unblock a blocked pipe · New New event API methods lack service tests · New
What changed in this PR
Adds pooled RCON support and an always-on, typed Minecraft event bus with REST/WebSocket exposure, persistence, and frontend integration.
Changes:
- Adds framed pooled RCON execution, reconnection, fallback, and validation.
- Parses, persists, and dispatches game events.
- Updates APIs, tests, OpenAPI, documentation, roadmap, and changelog.
| File | Reviewed changes and final review notes |
|---|---|
web/src/services/api.js |
Adds event API helpers. Nit: add coverage for parameters and cached event types. |
tests/api/test_rcon.py |
Adds RCON protocol, authentication, framing, and reconnection tests. |
tests/api/test_log_streaming.py |
Updates tests for the always-on follower contract. |
tests/api/test_events.py |
Adds event bus and endpoint tests. Moderate: the documented 500-item limit is not actually asserted. |
docs/INDEX.md |
Updates documentation navigation. |
docs/FAMILY_SERVER_ROADMAP.md |
Adds the family server roadmap. Nit: listed event names differ from shipped names. |
docs/EVENT_BUS.md |
Documents the event bus and event delivery. |
CHANGELOG.md |
Adds release notes for the new infrastructure. |
api/server.py |
Integrates RCON, event endpoints, log capture, and WebSocket validation. Critical: replayed tails duplicate events, 502 fallback can repeat sent commands, and non-string commands can escape error handling. Moderate: stopping may not terminate a blocked follower. |
api/rcon.py |
Adds the pooled RCON client. Critical: post-send failures can retry side-effecting commands. Moderate: cached credentials do not reflect password rotation. |
api/openapi.yaml |
Adds event API paths and schemas. |
api/events.py |
Adds parsing, persistence, dispatch, batching, and retention. Moderate: quiet periods may leave events unflushed, and concurrent flushes can write out of order. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Every finding from the Copilot review, plus the CodeQL notes. Each one was
verified against the code rather than taken on trust; all six were real.
Duplicate events on every restart
The follower attached with --tail 200 and persisted every line it read, so the
backlog was replayed into the bus on each API restart and each re-attach. The
same chat, join and death lines were recorded again and counts climbed with
every restart, which is exactly the flaw this work set out to remove from
scripts/player-stats-tracker.sh.
It now attaches with --tail 0. Connecting clients still get scrollback:
handle_connect sends get_log_tail() separately and that path does not touch the
bus. The trade is that events occurring while the API is down are not captured,
which is far better than recording some of them repeatedly.
Unsafe retries of non-idempotent commands
RconConnectionError could be raised after the command had already been written
to the socket, and both the client's retry and run_rcon_command's shell fallback
would then run it a second time. A lost response to a give, summon, kill or stop
would apply the effect twice.
Transmission is now separated from response reading. A failure on the first
write raises RconNotSentError, which is retried because nothing reached the
server. Any failure after that raises RconUnknownOutcomeError, which is never
retried and is reported by execute() as 500 so run_rcon_command will not re-run
it through the script either. Fallback remains for 502 and 503, both of which
mean nothing was sent.
Non-string commands escaped error handling
A truthy non-string value such as {"command": 1} reached
sanitize_minecraft_command() and raised on .lstrip(). On the REST path that
surfaced as a 500 instead of a 400; on the WebSocket path it raised before the
handler's try block, so the client received no command_error at all. Both paths
now reject the value explicitly, as does RconClient.command().
stop_log_reader() could not stop a blocked reader
It only set a flag. On a quiet server the reader sits blocked in
`for line in proc.stdout` and never reaches the check, so it kept running. The
active process is now tracked and killed, which breaks the read.
Event bus flush correctness
The buffer swap was locked but the file append was not, so two concurrent
flushes could interleave their lines; a dedicated write lock now serialises the
append without holding up publishes for the duration of the I/O. The size and
age thresholds were also only evaluated on publish, leaving the last few events
of a quiet period buffered indefinitely, so a daemon timer now flushes them.
Also
- The RCON config is re-read when its mtime changes, so rotating the password
with scripts/rcon-setup.sh no longer needs an API restart.
- Tests for both new event methods in web/src/services/api.js.
- The 500-item cap on GET /api/events is now actually asserted.
- The roadmap listed server_start/server_stop and omitted connect; it now names
the types that shipped.
- CodeQL: explained three empty except clauses, and removed the duplicate
module import in tests/api/test_events.py.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
All six review findings are fixed in e9a9a33, along with the four CodeQL notes and the three issues raised in the summary table. Each was verified against the code before acting; all six were real, and two of them had a second instance the review had not reached. Beyond the inline threads:
Two things I want to flag rather than leave implied:
🤖 Generated with Claude Code |



Description
Phase 0 of the new family server roadmap: two
pieces of plumbing that most of the planned gameplay features depend on, plus
the roadmap itself.
api/rcon.py— an in-process RCON clientEvery command previously shelled out to
scripts/rcon-client.sh, which opened anew TCP connection and re-authenticated for each one. That is roughly 50-100ms
of overhead per command and makes command batches impractical.
The script's Python fallback also had two defects:
sock.recv(4096), so responses longer than that weretruncated or split across reads.
checking for the
-1request id the protocol uses to signal auth failure.The new client holds one authenticated connection open behind a lock,
reassembles the multi-packet responses Minecraft sends for output over 4096
bytes, reconnects transparently when the container restarts underneath it, and
rejects commands long enough that the server would silently truncate them.
api/server.pyreaches it through a newrun_rcon_command()helper that fallsback to the shell script when RCON is unconfigured or unreachable, since that
script can also reach
rcon-cliinside the container when the port is notpublished to the host.
scripts/rcon-client.shis unchanged for CLI use.api/events.py— a game event busSee docs/EVENT_BUS.md.
The server log is the only real-time signal Minecraft produces. It was followed
purely to stream raw text to the browser, so anything reactive had to re-parse
raw lines for itself, and nothing was captured at all unless someone had the
dashboard open.
Lines are now parsed into typed events, appended to
data/events/YYYY-MM-DD.jsonl, and dispatched to registered handlers. Writesare batched and files older than 30 days are pruned, to limit SD-card wear on
the Pi. A handler that raises is logged and skipped rather than stopping the bus.
The follower now starts with the API rather than on the first browser
connection, and no longer stops when the last client disconnects. Events that
happened with the dashboard closed were previously lost.
stop_log_reader()brings it down deliberately.
Adds
GET /api/eventsandGET /api/events/types(bothlogs.view), agame_eventWebSocket message alongside the existing rawlogsstream,getEvents()andgetEventTypes()inweb/src/services/api.js, and thematching OpenAPI paths and schema.
Security fix included
The WebSocket
execute_commandhandler reached RCON without sanitising itsinput, bypassing the command allowlist that
POST /api/server/commandenforces. Anyone holding an API key could run arbitrary console commands through
it. It now runs the same validation and writes the same audit entries.
Notes for review
Two decisions worth a look:
connectandjoinare separate event types. A single player sessionlogs two lines, one carrying the network address and one saying they joined.
Mapping both to
joindouble-counted every session, which a smoke test on arealistic log caught.
tests/api/test_log_streaming.pywas updated, not worked around. Itasserted that the follower stops when the last viewer disconnects, which is
exactly the behaviour this PR removes on purpose. Those tests now cover the
new contract and the stop flag that replaced it.
Death detection matches the opening phrases of Minecraft's
death.attack.*strings, since vanilla death messages have no marker of their own. Chat is
matched first so a player typing a fake death message cannot fake a death event.
Anything the phrase list does not cover is simply not treated as a death, which
is the safe direction to fail.
Type of Change
Checklist
Verification run locally:
make test-apimake test-webmake lintdocker compose configThe RCON tests run against a real socket server speaking the protocol, so
framing, authentication, multi-packet reassembly and reconnection are exercised
end to end rather than mocked.
Related Issues
🤖 Generated with Claude Code