Files
link/sidecar/src/shard.rs
Claude 8751151abc docs: move docs to RunicGateway/docs, repoint all references
Extracted docs/ (ADMIN_CONTROLS, INTEGRATION, PLAN, PROTOCOL_2, RESEARCH,
SHARD_PREREQS) into the central RunicGateway/docs repo under link/, with
full commit history preserved via git filter-repo.

The source cites these design docs by section throughout, so every in-repo
reference (C# + Rust comments, Bridge.cfg, and the READMEs) is repointed at
the new docs-repo URL. README references are rendered as markdown links; a
Documentation pointer section is added to the top-level README.

Docs repo: https://gitea.whitlocktech.com/RunicGateway/docs
2026-07-18 00:08:34 -05:00

155 lines
5.5 KiB
Rust

//! The loopback link to the ServUO shard.
//!
//! The shard is the TCP *client*: it dials out to us. So the sidecar owns the listener, and the
//! shard's outbound socket is the only thing that ever connects. This is the whole reason the game
//! is never directly reachable from the website — it exposes no port. See https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md §2.
//!
//! Framing is newline-delimited JSON, bidirectional: the shard sends events, we send commands. We
//! accept one shard connection at a time and re-accept when it drops (the shard reconnects on its
//! own, with a bounded backoff).
use std::sync::Arc;
use serde_json::Value;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpListener;
use tokio::sync::{mpsc, Mutex};
use tracing::{info, warn};
/// An event line received from the shard, parsed. `kind` is lifted out for routing.
#[derive(Debug, Clone)]
pub struct ShardEvent {
pub kind: String,
pub value: Value,
}
/// A handle for sending command lines to the shard. Cloneable and cheap.
///
/// Commands are dropped (with a warning) when no shard is connected, rather than buffered: a
/// website query that arrives during a shard outage should fail fast and be retried, not silently
/// queue behind a reconnect. Live *events* are what must survive an outage, and those the shard
/// buffers on its side.
#[derive(Clone)]
pub struct ShardHandle {
tx: Arc<Mutex<Option<mpsc::UnboundedSender<String>>>>,
}
impl ShardHandle {
fn new() -> Self {
Self {
tx: Arc::new(Mutex::new(None)),
}
}
async fn set(&self, sender: Option<mpsc::UnboundedSender<String>>) {
*self.tx.lock().await = sender;
}
/// Send one command line (a complete JSON object, no newline — we add the frame delimiter).
/// Returns false if no shard is currently connected.
pub async fn send(&self, line: String) -> bool {
let guard = self.tx.lock().await;
match guard.as_ref() {
Some(sender) => sender.send(line).is_ok(),
None => {
warn!("dropping command; no shard connected");
false
}
}
}
pub async fn is_connected(&self) -> bool {
self.tx.lock().await.is_some()
}
}
/// Binds the loopback listener and accepts shard connections forever. Each accepted connection
/// runs until it drops, then we loop back to accept the next one. Events are forwarded to
/// `event_tx`; the returned handle sends commands to whichever shard is currently connected.
pub async fn serve(
addr: &str,
event_tx: mpsc::UnboundedSender<ShardEvent>,
) -> std::io::Result<ShardHandle> {
let listener = TcpListener::bind(addr).await?;
info!(%addr, "shard link listening");
let handle = ShardHandle::new();
let accept_handle = handle.clone();
tokio::spawn(async move {
loop {
match listener.accept().await {
Ok((stream, peer)) => {
info!(%peer, "shard connected");
if let Err(e) = handle_connection(stream, &event_tx, &accept_handle).await {
warn!(error = %e, "shard connection ended");
} else {
info!("shard disconnected");
}
accept_handle.set(None).await;
}
Err(e) => {
warn!(error = %e, "accept failed");
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
}
}
});
Ok(handle)
}
async fn handle_connection(
stream: tokio::net::TcpStream,
event_tx: &mpsc::UnboundedSender<ShardEvent>,
handle: &ShardHandle,
) -> std::io::Result<()> {
stream.set_nodelay(true).ok();
let (read_half, mut write_half) = stream.into_split();
// Install the outbound command channel for this connection.
let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel::<String>();
handle.set(Some(cmd_tx)).await;
let mut reader = BufReader::new(read_half);
let mut line = String::new();
loop {
tokio::select! {
// Inbound: a line from the shard.
result = reader.read_line(&mut line) => {
let n = result?;
if n == 0 {
return Ok(()); // clean EOF: shard closed
}
let trimmed = line.trim_end();
if !trimmed.is_empty() {
match serde_json::from_str::<Value>(trimmed) {
Ok(value) => {
let kind = value
.get("kind")
.and_then(|k| k.as_str())
.unwrap_or("")
.to_string();
let _ = event_tx.send(ShardEvent { kind, value });
}
Err(e) => warn!(error = %e, line = %trimmed, "unparseable event"),
}
}
line.clear();
}
// Outbound: a command to write to the shard.
cmd = cmd_rx.recv() => {
match cmd {
Some(mut c) => {
c.push('\n');
write_half.write_all(c.as_bytes()).await?;
write_half.flush().await?;
}
None => return Ok(()), // channel closed
}
}
}
}
}