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:
134
sidecar/src/main.rs
Normal file
134
sidecar/src/main.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user