Compare commits
2 Commits
a6cfa3abfb
...
ad141368c8
| Author | SHA1 | Date | |
|---|---|---|---|
| ad141368c8 | |||
| b3711e6778 |
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