feat(bridge): the Oxide plugin — protocol 1
RunicGateway.cs dials out to a rust-link sidecar on loopback and speaks newline-delimited JSON over it: server.hello on every connect, a pong to the sidecar's heartbeat, and one correlated server.status. The threading contract is the ServUO bridge's, unchanged, because the reason for it is the same on both games: * Emit is called from the main thread. It formats nothing, blocks on nothing and touches no socket -- it enqueues and returns, so a wedged or absent sidecar cannot stall the game. The queue is bounded, drop-oldest. * One link thread owns the socket, which keeps event ordering intact. * A reader thread marshals every inbound line to the main thread through Interface.Oxide.NextTick, and touches no Unity object, BasePlayer or ConVar. Settings come from Oxide's own config (oxide/config/RunicGateway.json), so an operator retunes the bridge the way they retune any other plugin -- and so it lands inside the site-side config editor a later phase adds. Four things the live rig corrected, none of which a unit test could have: * A disconnect was silent in the game console. The teardown log sat in the catch, and a connection ending because the READER saw EOF leaves the writer to exit cleanly -- nothing throws, so nothing was logged. A log in a catch only covers the failures that throw, and an orderly peer shutdown is not one. * Unload blocked the main thread for 1.9s (Oxide says so out loud), because the reconnect backoff was Thread.Sleep and Unload joins the link thread. Waiting on the AutoResetEvent that Unload already signals makes it immediate. The ServUO plugin has the same sleep and gets away with it only because ServUO does not hot-reload. * Mono's SocketException.Message is NUL-padded on Windows -- around 200 \0 bytes in the middle of the sentence, from a fixed-size OS buffer. \0 is not whitespace, so Trim does not touch it and neither does a whitespace-only collapse; the flattener has to treat control characters as separators. It took od -c on the log to see at all. * bootId regenerated on every PLUGIN load rather than every SERVER start. A fresh Guid at Init meant oxide.reload announced a brand new boot, and the website's reconcile design hangs off that value -- so every reload would have asked core to sweep its whole resource ledger for a world that never moved. It is now Process.StartTime: exact, identical on every read, and it changes when and only when the thing it names changes. rg.link reports the link's own counters from the console or over RCON, which is what separates 'the plugin is not loaded' from 'the plugin cannot reach the sidecar' from 'the website cannot reach the sidecar'. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
100
README.md
Normal file
100
README.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# Rust-Plugins
|
||||||
|
|
||||||
|
The **in-game half** of the Runic Gateway bridge for [Rust](https://rust.facepunch.com/): one Oxide
|
||||||
|
plugin that dials out to a [rust-link](https://gitea.whitlocktech.com/RunicGateway/Rust-Link)
|
||||||
|
sidecar and speaks newline-delimited JSON over it.
|
||||||
|
|
||||||
|
It is the mirror of
|
||||||
|
[`RunicGateway/servuo-plugins`](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins), which
|
||||||
|
does the same job for Ultima Online — and it inherits that plugin's threading contract wholesale,
|
||||||
|
because the reason for it is the same on both games.
|
||||||
|
|
||||||
|
## The threading contract
|
||||||
|
|
||||||
|
Everything else in this repo depends on these three:
|
||||||
|
|
||||||
|
- **`Emit` is called from the main thread. It formats nothing, blocks on nothing, and touches no
|
||||||
|
socket.** It enqueues and returns. A slow, wedged, or absent sidecar cannot stall the game.
|
||||||
|
- **One link thread owns the socket.** It connects, drains the queue, and reconnects with backoff.
|
||||||
|
A single writer keeps event ordering intact.
|
||||||
|
- **A reader thread parses inbound lines and hands each to the main thread** via
|
||||||
|
`Interface.Oxide.NextTick`. The reader touches no Unity object, no `BasePlayer` and no `ConVar` —
|
||||||
|
every one of those is main-thread-only, and reading one from the reader is the kind of bug that
|
||||||
|
presents as a crash somewhere else entirely.
|
||||||
|
|
||||||
|
The outbound queue is **bounded, drop-oldest**. On overflow the oldest record goes and is counted,
|
||||||
|
because telemetry is worth less than the server's memory.
|
||||||
|
|
||||||
|
## Loopback is the trust boundary
|
||||||
|
|
||||||
|
There is no token on the game link. The plugin and the sidecar share a host, and the sidecar binds
|
||||||
|
`127.0.0.1` — that is the authentication. Pointing `Host` at anything routable puts an
|
||||||
|
unauthenticated command channel on the network.
|
||||||
|
|
||||||
|
## Installing it
|
||||||
|
|
||||||
|
```
|
||||||
|
overlay/oxide/plugins/RunicGateway.cs → <server>/oxide/plugins/RunicGateway.cs
|
||||||
|
```
|
||||||
|
|
||||||
|
Oxide compiles and loads it on the write, and writes `oxide/config/RunicGateway.json` on first load:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Host": "127.0.0.1",
|
||||||
|
"Port": 7799,
|
||||||
|
"QueueCap": 5000,
|
||||||
|
"ServerId": "main"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`ServerId` is this server's stable identity across wipes and restarts, as the website knows it. It
|
||||||
|
is deliberately **not** derived from the hostname: an operator renames a server for a season, and
|
||||||
|
the site must not lose its history for it.
|
||||||
|
|
||||||
|
That is the developer's loop. An operator uses the
|
||||||
|
[installer](https://gitea.whitlocktech.com/RunicGateway/installer), which syncs the released overlay
|
||||||
|
tarball and installs the sidecar alongside it.
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
The bridge itself needs nothing but Oxide. The features that follow it read four third-party plugins
|
||||||
|
an operator installs from uMod — `Clans`, `Kits`, `PopupNotifications` and `ZoneManager`. They are
|
||||||
|
listed in `overlay.toml` so the installer's `doctor` can report a missing one by name rather than
|
||||||
|
leaving the site quietly short of a feature.
|
||||||
|
|
||||||
|
## Diagnosing it
|
||||||
|
|
||||||
|
```
|
||||||
|
rg.link
|
||||||
|
```
|
||||||
|
|
||||||
|
from the server console or over RCON. It reports the link's own counters:
|
||||||
|
|
||||||
|
```
|
||||||
|
protocol=1 serverId=main connected=True depth=0 sent=3 dropped=0 received=2
|
||||||
|
connects=1 writeErrors=0 bootId=boot-20260915T194502Z
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the first thing to ask for when the website says a server is offline — it separates "the
|
||||||
|
plugin is not loaded", "the plugin cannot reach the sidecar" and "the website cannot reach the
|
||||||
|
sidecar", which look identical from the site.
|
||||||
|
|
||||||
|
**`bootId` identifies the server PROCESS, not the plugin load.** It is the process start time, so
|
||||||
|
`oxide.reload RunicGateway` does not change it. That matters more than it looks: the website watches
|
||||||
|
this value to tell a game restart (everything an event put in the world is gone) from a bridge
|
||||||
|
reconnect (nothing is lost), and a plugin reload is the second kind.
|
||||||
|
|
||||||
|
## The protocol is a contract
|
||||||
|
|
||||||
|
`ProtocolVersion` in the plugin and `protocol` in `overlay.toml` must agree with the sidecar's
|
||||||
|
`PROTOCOL_VERSION` and the module's own constant. The installer refuses to pair an overlay and a
|
||||||
|
sidecar that disagree, so a bump landing in one repo and not the others fails to compose rather than
|
||||||
|
half-deploying.
|
||||||
|
|
||||||
|
The canonical spec is
|
||||||
|
[`docs/rust-link/PROTOCOL.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/rust-link/PROTOCOL.md).
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
GPL-3.0-or-later. See [LICENSE.md](LICENSE.md).
|
||||||
49
overlay.toml
Normal file
49
overlay.toml
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# Release metadata for the deployable overlay.
|
||||||
|
#
|
||||||
|
# Consumed by the release workflow, which folds these values into the
|
||||||
|
# manifest.json shipped inside the overlay tarball. The Runic Gateway installer
|
||||||
|
# reads that manifest to decide what it is deploying and whether it is compatible
|
||||||
|
# with the sidecar it is about to install.
|
||||||
|
#
|
||||||
|
# There is deliberately NO version key here. The release version is derived from
|
||||||
|
# git tags and conventional commits by the release workflow, so there is no bump
|
||||||
|
# commit to keep in sync and no way for this file to disagree with the tag.
|
||||||
|
|
||||||
|
# ── The loopback wire-protocol version this overlay speaks ───────────────────
|
||||||
|
#
|
||||||
|
# The plugin half of the compatibility contract. It MUST equal the sidecar's
|
||||||
|
# PROTOCOL_VERSION (Rust-Link's sidecar/src/main.rs) for a deployment to work:
|
||||||
|
# the sidecar rejects a mismatched WEBSITE with 409, and a mismatched PLUGIN is
|
||||||
|
# worse, because the game link has no such check — it would simply mis-parse.
|
||||||
|
#
|
||||||
|
# That asymmetry is why this file exists. The plugin announces its protocol in
|
||||||
|
# `server.hello`, which is only readable after the game server has booted with it
|
||||||
|
# loaded — far too late for an installer to refuse a bad pairing. This
|
||||||
|
# declaration is what lets the bundle CI check the pair BEFORE an operator
|
||||||
|
# installs either half.
|
||||||
|
#
|
||||||
|
# Keeping it honest is a manual duty: when the protocol changes, bump it here in
|
||||||
|
# the same change that alters the emitters, exactly as the sidecar bumps
|
||||||
|
# PROTOCOL_VERSION and the module bumps its own constant.
|
||||||
|
#
|
||||||
|
# Current: 1 — the transport (docs/rust-link/PROTOCOL.md).
|
||||||
|
protocol = 1
|
||||||
|
|
||||||
|
# ── Oxide compatibility ──────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# The overlay only ADDS a file — one plugin into `oxide/plugins/` — and patches
|
||||||
|
# nothing, so it is expected to work on any reasonably current Oxide. This is the
|
||||||
|
# oldest build it is known good on.
|
||||||
|
#
|
||||||
|
# There is no `patches_verified_against` key, and there is no `patches/` tier:
|
||||||
|
# Rust's server is a binary and Oxide's hook API is the supported way in, so
|
||||||
|
# there is nothing to diff against. That is the whole reason the Rust payload is
|
||||||
|
# simpler than the ServUO one.
|
||||||
|
min_oxide_version = "2.0.7585"
|
||||||
|
|
||||||
|
# The `oxide/plugins/` files this overlay expects to find already installed. They
|
||||||
|
# are not shipped here — they are third-party plugins an operator installs from
|
||||||
|
# uMod — and the installer's `doctor` reports a missing one rather than
|
||||||
|
# installing it. Listing them is what turns "the site shows no clans" into a
|
||||||
|
# named prerequisite.
|
||||||
|
requires_plugins = ["Clans", "Kits", "PopupNotifications", "ZoneManager"]
|
||||||
709
overlay/oxide/plugins/RunicGateway.cs
Normal file
709
overlay/oxide/plugins/RunicGateway.cs
Normal file
@@ -0,0 +1,709 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using Oxide.Core;
|
||||||
|
using Oxide.Core.Plugins;
|
||||||
|
|
||||||
|
namespace Oxide.Plugins
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The Runic Gateway bridge. Dials out to a rust-link sidecar on loopback and speaks
|
||||||
|
/// newline-delimited JSON over it: events out, commands in.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Threading contract, which everything later in this plugin depends on:
|
||||||
|
/// </para>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description>
|
||||||
|
/// <see cref="Emit"/> is called from the server's main thread. It formats nothing,
|
||||||
|
/// blocks on nothing, and touches no socket. It enqueues and returns. A slow, wedged,
|
||||||
|
/// or absent sidecar cannot stall the game.
|
||||||
|
/// </description></item>
|
||||||
|
/// <item><description>
|
||||||
|
/// One link thread owns the socket. It connects, drains the queue, and reconnects with
|
||||||
|
/// backoff. A single writer keeps event ordering intact.
|
||||||
|
/// </description></item>
|
||||||
|
/// <item><description>
|
||||||
|
/// A reader thread parses inbound lines and hands each to the main thread via
|
||||||
|
/// <c>Interface.Oxide.NextTick</c>. The reader touches no Unity object, no BasePlayer
|
||||||
|
/// and no ConVar — every one of those is main-thread-only, and reading them from here
|
||||||
|
/// is the kind of bug that presents as a crash somewhere else entirely.
|
||||||
|
/// </description></item>
|
||||||
|
/// </list>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// The outbound queue is bounded. On overflow the oldest record is dropped and counted,
|
||||||
|
/// because telemetry is worth less than the server's memory.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>Loopback is the trust boundary.</b> There is no token on this link: the plugin and
|
||||||
|
/// the sidecar share a host, and the sidecar binds 127.0.0.1. Pointing <c>Host</c> at
|
||||||
|
/// anything routable puts an unauthenticated command channel on the network.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
[Info("Runic Gateway", "RunicGateway", "0.1.0")]
|
||||||
|
[Description("Bridges this Rust server to a Runic Gateway website through a rust-link sidecar.")]
|
||||||
|
internal class RunicGateway : RustPlugin
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Wire-protocol version. Declared in three places that must agree: here,
|
||||||
|
/// <c>PROTOCOL_VERSION</c> in the sidecar, and <c>module.json</c> in Module-Rust. The
|
||||||
|
/// overlay's <c>overlay.toml</c> carries this same number so the installer can refuse
|
||||||
|
/// to pair a sidecar and an overlay that disagree.
|
||||||
|
/// </summary>
|
||||||
|
private const int ProtocolVersion = 1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loopback reconnects are cheap, so the ceiling is low. A sidecar restart should cost
|
||||||
|
/// a few seconds of buffering, not half a minute.
|
||||||
|
/// </summary>
|
||||||
|
private const int MaxBackoffMs = 5000;
|
||||||
|
|
||||||
|
/// <summary>The longest line accepted from the sidecar. Over-long lines are discarded.</summary>
|
||||||
|
private const int MaxInboundLineBytes = 1024 * 1024;
|
||||||
|
|
||||||
|
private static readonly ConcurrentQueue<string> Outbound = new ConcurrentQueue<string>();
|
||||||
|
private static readonly AutoResetEvent Wake = new AutoResetEvent(false);
|
||||||
|
|
||||||
|
private PluginConfig _config;
|
||||||
|
|
||||||
|
private Thread _link;
|
||||||
|
private volatile bool _running;
|
||||||
|
private volatile bool _connected;
|
||||||
|
|
||||||
|
/// <summary>Set when the peer goes away, so the writer stops trying.</summary>
|
||||||
|
private volatile bool _dead;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the "cannot reach the sidecar" line has been printed for this load. It is
|
||||||
|
/// printed once, not per attempt: the retry runs every few seconds for ever.
|
||||||
|
/// </summary>
|
||||||
|
private volatile bool _loggedConnectFailure;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Incremented per connection attempt. A reader from a previous connection must not be
|
||||||
|
/// able to mark a newer one dead — <c>reader.Join</c> can time out, and the stale
|
||||||
|
/// thread's finally block would otherwise tear down the connection that replaced it.
|
||||||
|
/// </summary>
|
||||||
|
private int _epoch;
|
||||||
|
|
||||||
|
private int _depth;
|
||||||
|
private long _sent, _dropped, _received, _connects, _writeErrors;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Identifies this run of the <b>server process</b>. It is how the website tells a game
|
||||||
|
/// restart apart from a sidecar reconnect — the same link comes back either way, and
|
||||||
|
/// only one of the two means the world it is describing started over.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// It must therefore be stable across a <b>plugin</b> reload, which a fresh
|
||||||
|
/// <c>Guid</c> at <c>Init</c> is not: <c>oxide.reload</c> would announce a brand new
|
||||||
|
/// boot to a website that is about to reconcile its whole ledger on the strength of it,
|
||||||
|
/// while the world it describes never moved. The process's own start time is the only
|
||||||
|
/// value here that changes exactly when the thing it names changes.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
private string _bootId;
|
||||||
|
|
||||||
|
// ---- lifecycle ----
|
||||||
|
|
||||||
|
private void Init()
|
||||||
|
{
|
||||||
|
_bootId = ResolveBootId();
|
||||||
|
_running = true;
|
||||||
|
|
||||||
|
_link = new Thread(LinkLoop)
|
||||||
|
{
|
||||||
|
Name = "RunicGateway Link",
|
||||||
|
IsBackground = true
|
||||||
|
};
|
||||||
|
|
||||||
|
_link.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Unload()
|
||||||
|
{
|
||||||
|
_running = false;
|
||||||
|
Wake.Set();
|
||||||
|
|
||||||
|
Thread t = _link;
|
||||||
|
|
||||||
|
if (t != null && !t.Join(TimeSpan.FromSeconds(2.0)))
|
||||||
|
Puts("link thread did not stop cleanly");
|
||||||
|
|
||||||
|
_link = null;
|
||||||
|
_connected = false;
|
||||||
|
|
||||||
|
// A reload leaves the static queue holding lines addressed to a connection that no
|
||||||
|
// longer exists. Clearing it is the difference between a reload and a slow leak.
|
||||||
|
string discard;
|
||||||
|
while (Outbound.TryDequeue(out discard))
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
Interlocked.Exchange(ref _depth, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- configuration ----
|
||||||
|
|
||||||
|
private class PluginConfig
|
||||||
|
{
|
||||||
|
/// <summary>Where the sidecar listens. Loopback is the trust boundary; see the class docs.</summary>
|
||||||
|
public string Host = "127.0.0.1";
|
||||||
|
|
||||||
|
public int Port = 7799;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How many outbound lines may wait for a wedged sidecar before the oldest is
|
||||||
|
/// dropped. Sized in lines rather than bytes because that is what is cheap to
|
||||||
|
/// count; the inbound cap is what bounds a single line's size.
|
||||||
|
/// </summary>
|
||||||
|
public int QueueCap = 5000;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// This server's stable identity across wipes and restarts, as the website knows
|
||||||
|
/// it. It is <b>not</b> derived from the hostname: an operator renames a server for
|
||||||
|
/// a season and the site must not lose its history for it.
|
||||||
|
/// </summary>
|
||||||
|
public string ServerId = "main";
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void LoadDefaultConfig()
|
||||||
|
{
|
||||||
|
Config.WriteObject(new PluginConfig(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LoadConfigValues()
|
||||||
|
{
|
||||||
|
_config = Config.ReadObject<PluginConfig>() ?? new PluginConfig();
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(_config.Host))
|
||||||
|
_config.Host = "127.0.0.1";
|
||||||
|
|
||||||
|
if (_config.Port <= 0 || _config.Port > 65535)
|
||||||
|
_config.Port = 7799;
|
||||||
|
|
||||||
|
// A cap of zero would drop every line on the way in and report a healthy link doing
|
||||||
|
// nothing, which is the worst failure available here.
|
||||||
|
if (_config.QueueCap < 16)
|
||||||
|
_config.QueueCap = 16;
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(_config.ServerId))
|
||||||
|
_config.ServerId = "main";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Loaded()
|
||||||
|
{
|
||||||
|
LoadConfigValues();
|
||||||
|
Puts($"protocol {ProtocolVersion}, serverId '{_config.ServerId}', sidecar {_config.Host}:{_config.Port}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the outbound queue ----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Main thread. Non-blocking. <paramref name="line"/> must already be a complete JSON
|
||||||
|
/// object with no embedded newline; the newline is appended by the writer as the frame
|
||||||
|
/// delimiter.
|
||||||
|
/// </summary>
|
||||||
|
private void Emit(string line)
|
||||||
|
{
|
||||||
|
if (line == null || !_running)
|
||||||
|
return;
|
||||||
|
|
||||||
|
int cap = _config != null ? _config.QueueCap : 5000;
|
||||||
|
|
||||||
|
// Drop-oldest. Bound first, then enqueue, so the queue can transiently sit one over the
|
||||||
|
// cap but never grows without limit.
|
||||||
|
while (Volatile.Read(ref _depth) >= cap)
|
||||||
|
{
|
||||||
|
string discard;
|
||||||
|
|
||||||
|
if (!Outbound.TryDequeue(out discard))
|
||||||
|
break;
|
||||||
|
|
||||||
|
Interlocked.Decrement(ref _depth);
|
||||||
|
Interlocked.Increment(ref _dropped);
|
||||||
|
}
|
||||||
|
|
||||||
|
Outbound.Enqueue(line);
|
||||||
|
Interlocked.Increment(ref _depth);
|
||||||
|
|
||||||
|
Wake.Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EmitFrame(Dictionary<string, object> frame)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Emit(JsonConvert.SerializeObject(frame, Formatting.None));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// A frame that cannot be serialized must not take the caller down with it — the
|
||||||
|
// caller is usually a game hook.
|
||||||
|
Puts($"could not serialize frame: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the link ----
|
||||||
|
|
||||||
|
private void LinkLoop()
|
||||||
|
{
|
||||||
|
int backoffMs = 500;
|
||||||
|
|
||||||
|
while (_running)
|
||||||
|
{
|
||||||
|
TcpClient client = null;
|
||||||
|
Thread reader = null;
|
||||||
|
|
||||||
|
int epoch = Interlocked.Increment(ref _epoch);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string host = _config != null ? _config.Host : "127.0.0.1";
|
||||||
|
int port = _config != null ? _config.Port : 7799;
|
||||||
|
|
||||||
|
client = new TcpClient();
|
||||||
|
client.NoDelay = true;
|
||||||
|
client.Connect(host, port);
|
||||||
|
|
||||||
|
NetworkStream stream = client.GetStream();
|
||||||
|
stream.WriteTimeout = 5000; // a wedged peer must surface as an error, not a hang
|
||||||
|
|
||||||
|
_dead = false;
|
||||||
|
_connected = true;
|
||||||
|
_loggedConnectFailure = false; // so a LATER outage is reported again
|
||||||
|
backoffMs = 500;
|
||||||
|
|
||||||
|
Interlocked.Increment(ref _connects);
|
||||||
|
Puts($"connected to {host}:{port}");
|
||||||
|
|
||||||
|
// Building the greeting reads the world, so it must happen on the main thread.
|
||||||
|
// It is sent on every connect rather than once at startup: the sidecar restarts
|
||||||
|
// independently of the game, and anything it needs up front has to be re-sent
|
||||||
|
// per connection.
|
||||||
|
Interface.Oxide.NextTick(SendHello);
|
||||||
|
|
||||||
|
NetworkStream localStream = stream;
|
||||||
|
reader = new Thread(() => ReadLoop(localStream, epoch))
|
||||||
|
{
|
||||||
|
Name = "RunicGateway Reader",
|
||||||
|
IsBackground = true
|
||||||
|
};
|
||||||
|
reader.Start();
|
||||||
|
|
||||||
|
WriteLoop(stream);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Socket exception messages carry EMBEDDED newlines on Windows — the refusal
|
||||||
|
// text and the endpoint arrive on separate lines — so trimming the ends is not
|
||||||
|
// enough to keep the Oxide log one entry per line.
|
||||||
|
string reason = Flatten(ex.Message);
|
||||||
|
|
||||||
|
if (_connected)
|
||||||
|
{
|
||||||
|
Puts($"link error: {reason}");
|
||||||
|
}
|
||||||
|
else if (!_loggedConnectFailure)
|
||||||
|
{
|
||||||
|
// The FIRST failure to connect, and only the first: a sidecar that is not
|
||||||
|
// running yet is normal at boot and would otherwise spam the console every
|
||||||
|
// few seconds, but a wrong Host or Port is otherwise silent for ever.
|
||||||
|
_loggedConnectFailure = true;
|
||||||
|
Puts($"cannot reach the sidecar: {reason} — retrying quietly until it answers");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
// Captured before it is cleared: a connection that ended because the READER saw
|
||||||
|
// EOF leaves the writer to exit cleanly, so nothing throws and the catch above
|
||||||
|
// never runs. Without this the link going down is invisible in the game console,
|
||||||
|
// which is the one place an operator looks first.
|
||||||
|
bool wasConnected = _connected;
|
||||||
|
|
||||||
|
_connected = false;
|
||||||
|
_dead = true;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (client != null)
|
||||||
|
client.Close();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Closing a socket that is already gone is not news.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reader != null)
|
||||||
|
reader.Join(TimeSpan.FromSeconds(1.0));
|
||||||
|
|
||||||
|
if (wasConnected)
|
||||||
|
Puts("sidecar link lost; reconnecting");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_running)
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Nothing is listening yet, or the sidecar restarted. Both are normal.
|
||||||
|
//
|
||||||
|
// `Wake`, not `Thread.Sleep`: Unload runs on the MAIN thread and joins this one, so
|
||||||
|
// an uninterruptible sleep here is a stall of up to MaxBackoffMs on every reload —
|
||||||
|
// Oxide reports it as `Calling 'Unload' took 1918ms`, and the server is frozen for
|
||||||
|
// that whole time. Unload sets `_running` false and signals `Wake`, so waiting on
|
||||||
|
// the handle turns that stall into an immediate return.
|
||||||
|
Wake.WaitOne(backoffMs);
|
||||||
|
backoffMs = Math.Min(backoffMs * 2, MaxBackoffMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
_connected = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WriteLoop(NetworkStream stream)
|
||||||
|
{
|
||||||
|
while (_running && !_dead)
|
||||||
|
{
|
||||||
|
string line;
|
||||||
|
|
||||||
|
if (!Outbound.TryDequeue(out line))
|
||||||
|
{
|
||||||
|
Wake.WaitOne(250);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Interlocked.Decrement(ref _depth);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
byte[] bytes = Encoding.UTF8.GetBytes(line + "\n");
|
||||||
|
stream.Write(bytes, 0, bytes.Length);
|
||||||
|
Interlocked.Increment(ref _sent);
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// The record is already off the queue. Count it and let the outer loop
|
||||||
|
// reconnect; re-queueing risks an unbounded retry storm against a dead peer.
|
||||||
|
Interlocked.Increment(ref _writeErrors);
|
||||||
|
_dead = true;
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReadLoop(NetworkStream stream, int epoch)
|
||||||
|
{
|
||||||
|
byte[] buffer = new byte[8192];
|
||||||
|
StringBuilder line = new StringBuilder(512);
|
||||||
|
bool discarding = false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (_running && !_dead)
|
||||||
|
{
|
||||||
|
int read = stream.Read(buffer, 0, buffer.Length);
|
||||||
|
|
||||||
|
if (read <= 0)
|
||||||
|
break; // clean EOF: the sidecar closed. Normal.
|
||||||
|
|
||||||
|
for (int i = 0; i < read; i++)
|
||||||
|
{
|
||||||
|
char c = (char)buffer[i];
|
||||||
|
|
||||||
|
if (c == '\n')
|
||||||
|
{
|
||||||
|
if (discarding)
|
||||||
|
{
|
||||||
|
// The tail of a line already given up on. Swallowing it here is
|
||||||
|
// what stops the remainder being parsed as a line of its own.
|
||||||
|
discarding = false;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Dispatch(line.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
line.Length = 0;
|
||||||
|
}
|
||||||
|
else if (c != '\r')
|
||||||
|
{
|
||||||
|
if (discarding)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
line.Append(c);
|
||||||
|
|
||||||
|
if (line.Length > MaxInboundLineBytes)
|
||||||
|
{
|
||||||
|
Puts("inbound line over the cap; discarding to the next newline");
|
||||||
|
line.Length = 0;
|
||||||
|
discarding = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (System.IO.IOException)
|
||||||
|
{
|
||||||
|
// Expected when the peer vanishes mid-read.
|
||||||
|
}
|
||||||
|
catch (ObjectDisposedException)
|
||||||
|
{
|
||||||
|
// Expected when Unload closes the socket under us.
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Puts($"reader error: {ex.Message}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
// Only tear down the connection this reader actually owned.
|
||||||
|
if (Volatile.Read(ref _epoch) == epoch)
|
||||||
|
{
|
||||||
|
_dead = true;
|
||||||
|
Wake.Set(); // let the writer notice and fall through to reconnect
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reader thread. Marshals to the main thread; nothing that touches the game happens
|
||||||
|
/// here.
|
||||||
|
/// </summary>
|
||||||
|
private void Dispatch(string line)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(line))
|
||||||
|
return;
|
||||||
|
|
||||||
|
Interlocked.Increment(ref _received);
|
||||||
|
|
||||||
|
Interface.Oxide.NextTick(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
HandleCommand(line);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// A malformed command must never escape into a game code path.
|
||||||
|
Puts($"inbound handler threw: {ex}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- commands ----
|
||||||
|
|
||||||
|
/// <summary>Main thread. Every world read below is safe only because of that.</summary>
|
||||||
|
private void HandleCommand(string line)
|
||||||
|
{
|
||||||
|
Dictionary<string, object> cmd;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
cmd = JsonConvert.DeserializeObject<Dictionary<string, object>>(line);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Puts($"unparseable command: {ex.Message}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cmd == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
object verbObj;
|
||||||
|
if (!cmd.TryGetValue("cmd", out verbObj) || verbObj == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
string verb = verbObj.ToString();
|
||||||
|
object reqIdObj;
|
||||||
|
string reqId = cmd.TryGetValue("reqId", out reqIdObj) && reqIdObj != null
|
||||||
|
? reqIdObj.ToString()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
switch (verb)
|
||||||
|
{
|
||||||
|
case "ping":
|
||||||
|
EmitFrame(new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "kind", "pong" },
|
||||||
|
{ "t", NowMs() }
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "server.status":
|
||||||
|
Dictionary<string, object> status = ServerStatus();
|
||||||
|
status["kind"] = "server.status";
|
||||||
|
status["t"] = NowMs();
|
||||||
|
|
||||||
|
// The correlation id is echoed verbatim, and only when one was supplied. A
|
||||||
|
// reply that invents one would be routed to nobody; a reply that omits one the
|
||||||
|
// caller sent would leave that caller waiting out its whole timeout.
|
||||||
|
if (reqId != null)
|
||||||
|
status["reqId"] = reqId;
|
||||||
|
|
||||||
|
EmitFrame(status);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
Puts($"unknown command '{verb}'");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- frames ----
|
||||||
|
|
||||||
|
/// <summary>Main thread.</summary>
|
||||||
|
private void SendHello()
|
||||||
|
{
|
||||||
|
Dictionary<string, object> hello = ServerStatus();
|
||||||
|
|
||||||
|
hello["kind"] = "server.hello";
|
||||||
|
hello["t"] = NowMs();
|
||||||
|
hello["protocol"] = ProtocolVersion;
|
||||||
|
hello["bootId"] = _bootId;
|
||||||
|
hello["plugin"] = Version.ToString();
|
||||||
|
|
||||||
|
EmitFrame(hello);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Main thread. The shared body of <c>server.hello</c> and <c>server.status</c> — the
|
||||||
|
/// two frames differ in what wraps them, not in what they say about the server, and
|
||||||
|
/// letting them drift is how a site ends up showing two different player counts.
|
||||||
|
/// </summary>
|
||||||
|
private Dictionary<string, object> ServerStatus()
|
||||||
|
{
|
||||||
|
var status = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "serverId", _config != null ? _config.ServerId : "main" },
|
||||||
|
{ "hostname", ConVar.Server.hostname },
|
||||||
|
{ "description", ConVar.Server.description },
|
||||||
|
{ "level", ConVar.Server.level },
|
||||||
|
{ "seed", ConVar.Server.seed },
|
||||||
|
{ "worldSize", ConVar.Server.worldsize },
|
||||||
|
{ "maxPlayers", ConVar.Server.maxplayers },
|
||||||
|
{ "players", BasePlayer.activePlayerList != null ? BasePlayer.activePlayerList.Count : 0 },
|
||||||
|
{ "joining", ServerMgr.Instance != null ? ServerMgr.Instance.connectionQueue.Joining : 0 },
|
||||||
|
{ "queued", ServerMgr.Instance != null ? ServerMgr.Instance.connectionQueue.Queued : 0 },
|
||||||
|
{ "uptimeSec", (int)UnityEngine.Time.realtimeSinceStartup }
|
||||||
|
};
|
||||||
|
|
||||||
|
// The raw material a wipe id is derived from, rather than a wipe id. Deriving it is the
|
||||||
|
// website's job and it is not yet specified; emitting a guess now would bake a wrong
|
||||||
|
// one into stored rows, which is the expensive half to undo.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
status["saveCreatedAt"] = SaveRestore.SaveCreatedTime.ToUniversalTime()
|
||||||
|
.ToString("yyyy-MM-ddTHH:mm:ssZ", System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// A server that has never saved has no created time. Absent, not zero.
|
||||||
|
}
|
||||||
|
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Collapses every run of whitespace <b>and control characters</b> to one space, so an
|
||||||
|
/// exception message stays one log entry.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// The control half is not defensive padding. Mono's <c>SocketException.Message</c> on
|
||||||
|
/// Windows comes back from a fixed-size OS buffer and is <b>NUL-padded</b> — a couple
|
||||||
|
/// of hundred <c>\0</c> bytes sitting in the middle of the sentence. <c>\0</c> is not
|
||||||
|
/// whitespace, so <c>Trim</c> does not touch it and neither does a whitespace-only
|
||||||
|
/// collapse; the line looks like it contains a huge run of spaces and no amount of
|
||||||
|
/// trimming removes it.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
private static string Flatten(string text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(text))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
var sb = new StringBuilder(text.Length);
|
||||||
|
bool lastWasSpace = false;
|
||||||
|
|
||||||
|
foreach (char c in text)
|
||||||
|
{
|
||||||
|
if (char.IsWhiteSpace(c) || char.IsControl(c))
|
||||||
|
{
|
||||||
|
if (!lastWasSpace && sb.Length > 0)
|
||||||
|
sb.Append(' ');
|
||||||
|
|
||||||
|
lastWasSpace = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sb.Append(c);
|
||||||
|
lastWasSpace = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.ToString().TrimEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The server process's start instant, as <c>boot-yyyyMMddTHHmmssZ</c>.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <c>Process.StartTime</c> is exact and identical on every read, which is what makes
|
||||||
|
/// the id survive a plugin reload. The fallback derives the same instant from Unity's
|
||||||
|
/// monotonic clock, and is only reached if the platform refuses the process query; it
|
||||||
|
/// is rounded to the second deliberately, because the subtraction jitters in the last
|
||||||
|
/// decimal and an id that changes on rounding is worse than one a second off.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
private static string ResolveBootId()
|
||||||
|
{
|
||||||
|
DateTime startUtc;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
startUtc = System.Diagnostics.Process.GetCurrentProcess().StartTime.ToUniversalTime();
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
double up = UnityEngine.Time.realtimeSinceStartup;
|
||||||
|
startUtc = DateTime.UtcNow.AddSeconds(-Math.Round(up));
|
||||||
|
}
|
||||||
|
|
||||||
|
startUtc = new DateTime(
|
||||||
|
startUtc.Year, startUtc.Month, startUtc.Day,
|
||||||
|
startUtc.Hour, startUtc.Minute, startUtc.Second,
|
||||||
|
DateTimeKind.Utc);
|
||||||
|
|
||||||
|
return "boot-" + startUtc.ToString(
|
||||||
|
"yyyyMMdd'T'HHmmss'Z'", System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long NowMs()
|
||||||
|
{
|
||||||
|
return (long)(DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc))
|
||||||
|
.TotalMilliseconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- diagnostics ----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The link's own counters, from the server console. This is how an operator answers
|
||||||
|
/// "is the bridge working" without reading a log, and it is the first thing to ask for
|
||||||
|
/// when the website says a server is offline.
|
||||||
|
/// </summary>
|
||||||
|
[ConsoleCommand("rg.link")]
|
||||||
|
private void CmdLink(ConsoleSystem.Arg arg)
|
||||||
|
{
|
||||||
|
arg.ReplyWith(
|
||||||
|
$"protocol={ProtocolVersion} serverId={(_config != null ? _config.ServerId : "?")} " +
|
||||||
|
$"connected={_connected} depth={Volatile.Read(ref _depth)} " +
|
||||||
|
$"sent={Interlocked.Read(ref _sent)} dropped={Interlocked.Read(ref _dropped)} " +
|
||||||
|
$"received={Interlocked.Read(ref _received)} connects={Interlocked.Read(ref _connects)} " +
|
||||||
|
$"writeErrors={Interlocked.Read(ref _writeErrors)} bootId={_bootId}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user