feat(sidecar): protocol 1 — the transport

The rust-link sidecar: it owns the loopback listener the Oxide bridge plugin
dials into, and serves the website a WebSocket feed plus store-backed reads.

Protocol 1 is deliberately three frames — server.hello, ping/pong, and one
correlated server.status — because phase 1's job is to get every seam working at
once with almost nothing in them.

What is load-bearing rather than incidental:

* The plugin is the TCP client and this process owns the listener, so a Rust
  server opens no extra port. Loopback is the trust boundary on that link and
  there is no token on it; the website-facing surface is the opposite, with auth
  always on and a token generated and persisted on first start.
* Inbound lines are capped at 1 MiB from the start rather than after the first
  large frame arrives. An over-long line is discarded and the connection stays
  up: one malformed frame is not a reason to drop a link live events flow over.
* Store-backed reads answer while the game is off, which is what lets a website
  render a server list during a wipe. /status is the one route that fails when
  the game is down, and /server answers 204 rather than a null when the game has
  never connected -- those are different answers and a client that cannot tell
  them apart renders a server that does not exist.
* The two RPC failures get distinct codes. 503 means the game is down; 504 means
  it is up and did not answer. Different fixes.
* rpc::REPLY_TIMEOUT is a ceiling every later command budget sits under: core
  classifies a budget overrun as retryable unconditionally, so an action whose
  budgetMs does not exceed it can never report retry:false.

One defect found while building, which no unit test would have caught: a
four-connection SQLite pool over :memory: hands out four separate empty
databases, because an in-memory database is per connection. It presents as
'no such table' from a random subset of queries. The pool is now capped at one
connection for an in-memory path, which is the only coherent reading of
:memory: and is what makes it usable at all.

Exercised end to end against a live Rust server: a server.hello travelled game
-> sidecar -> module -> the public website API, and killing this process left
the game untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
2026-09-15 19:52:55 -05:00
parent d6a93506e8
commit e2a58f3455
12 changed files with 4651 additions and 0 deletions

134
sidecar/src/main.rs Normal file
View File

@@ -0,0 +1,134 @@
//! rust-link sidecar.
//!
//! Terminates the loopback link to a Rust game server's Oxide bridge plugin and exposes a
//! website-facing HTTP surface: a WebSocket live feed and REST queries backed by SQLite.
//!
//! # Why the game does not listen
//!
//! The plugin is the TCP *client*; this process owns the listener. A Rust server therefore opens
//! no extra port, and the only component the website can reach is this one. That invariant is
//! inherited wholesale from the ServUO bridge — the footing changed (Oxide hooks instead of shard
//! source) and the shape did not.
//!
//! # One server, one sidecar
//!
//! This binary serves exactly one game server. A community running six servers runs six pairs, and
//! `module-rust` holds six clients; core never learns there is more than one. Nothing here is
//! multiplexed, and nothing here should become multiplexed — the `serverId` on every frame exists
//! so the *module* can tell its own clients apart, not so this process can.
//!
//! # Layout
//!
//! `main` does argument handling and nothing else; the sidecar proper lives in [`app`], which is
//! parameterised on `ready`/`shutdown` so that a future service wrapper (the installer's phase)
//! can supply the host's own start and stop without restructuring anything.
mod app;
mod cli;
mod config;
mod game;
mod rpc;
mod store;
mod web;
use tracing_subscriber::EnvFilter;
/// Wire-protocol version between the website and this sidecar, and between this sidecar and the
/// bridge plugin. Bump it whenever a frame's shape changes, so a mismatched peer is detected
/// immediately (`409` on HTTP, a refusal in the log on the game link) rather than mis-parsed.
///
/// It is declared in **three** places and they must agree: here, in `Module-Rust`'s
/// `module.json`, and in `Rust-Plugins`' `overlay.toml`. The installer refuses to pair a sidecar
/// and an overlay that disagree, so a bump lands in the same change as the emitters it describes.
///
/// # Protocol 1 — the transport
///
/// Everything phase 1 defines, and deliberately nothing more:
///
/// * **`server.hello`** — the one event. The plugin sends it on every successful connect, not
/// once at server start: this process restarts independently of the game, so anything the
/// sidecar needs up front has to be re-sent per connection. It carries the `bootId`, which is
/// how a game restart is told apart from a sidecar reconnect — the distinction the event
/// system's `reconcile` will later hang off.
/// * **`ping` / `pong`** — the heartbeat. The sidecar asks, the plugin answers. A `pong` is
/// ephemeral chatter and is never persisted; it only moves `last_event`.
/// * **`server.status`** — the one request/reply verb, correlated by `reqId`. It exists in
/// phase 1 so the correlation path is exercised by something before anything depends on it.
///
/// Both directions are newline-delimited JSON over TCP. Outbound frames (plugin -> sidecar) carry
/// `kind`; inbound frames (sidecar -> plugin) carry `cmd`.
pub const PROTOCOL_VERSION: u32 = 1;
fn main() -> anyhow::Result<()> {
let args = match cli::parse(std::env::args().skip(1)) {
Ok(args) => args,
Err(msg) => {
eprintln!("rust-link-sidecar: {msg}\n\n{}", cli::USAGE);
std::process::exit(2);
}
};
match args.mode {
cli::Mode::Help => {
print!("{}", cli::USAGE);
return Ok(());
}
cli::Mode::Version => {
println!(
"rust-link-sidecar {} (protocol {})",
env!("CARGO_PKG_VERSION"),
PROTOCOL_VERSION
);
return Ok(());
}
// Tracing stays uninitialized here on purpose: the subscriber writes to stdout, and on this
// path stdout is the document. An installer parses it.
cli::Mode::PrintConfig => {
let loaded = config::Config::load(args.config.as_deref())?;
println!("{:#}", config::describe(&loaded));
return Ok(());
}
cli::Mode::Run => {}
}
init_console_tracing();
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
runtime.block_on(app::run(args.config.as_deref(), || {}, shutdown_signal()))
}
/// Logging for a foreground run: human-readable, on stdout.
pub fn init_console_tracing() {
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
)
.init();
}
/// Resolves on Ctrl-C, and on `SIGTERM` where there is one.
async fn shutdown_signal() {
#[cfg(unix)]
{
use tokio::signal::unix::{signal, SignalKind};
let mut term = match signal(SignalKind::terminate()) {
Ok(s) => s,
Err(_) => {
let _ = tokio::signal::ctrl_c().await;
return;
}
};
tokio::select! {
_ = tokio::signal::ctrl_c() => {}
_ = term.recv() => {}
}
}
#[cfg(not(unix))]
{
let _ = tokio::signal::ctrl_c().await;
}
}