diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs
index ef401a0..dd1fb84 100644
--- a/overlay/oxide/plugins/RunicGateway.cs
+++ b/overlay/oxide/plugins/RunicGateway.cs
@@ -67,6 +67,25 @@ namespace Oxide.Plugins
/// The longest line accepted from the sidecar. Over-long lines are discarded.
private const int MaxInboundLineBytes = 1024 * 1024;
+ ///
+ /// How long to wait for the TCP connect itself before giving up and backing off.
+ ///
+ ///
+ /// It exists because TcpClient.Connect is blocking and has no timeout of its
+ /// own, and the OS default is tens of seconds. A host that REFUSES answers instantly —
+ /// which is every loopback test, and why this was invisible for two phases — but a host
+ /// that drops does not answer at all: a firewall, a wrong address, a machine
+ /// that is off. The link thread then sits in the connect, deaf to everything, and
+ /// Unload waits out its whole join on the game's MAIN THREAD.
+ ///
+ ///
+ ///
+ /// Generous against any real network and short against a hang. Loopback, which is the
+ /// supported deployment, connects in well under a millisecond.
+ ///
+ ///
+ private const int ConnectTimeoutMs = 3000;
+
///
/// How often the boards are re-sent (PROTOCOL.md §8.3) and the per-player tallies are
/// flushed (§8.6). One cadence serves both because both answer the same question —
@@ -84,6 +103,18 @@ namespace Oxide.Plugins
private static readonly ConcurrentQueue Outbound = new ConcurrentQueue();
private static readonly AutoResetEvent Wake = new AutoResetEvent(false);
+ ///
+ /// Set once, by Unload, and never reset. It is what lets a thread blocked on a
+ /// CONNECT give up immediately instead of at the end of its timeout.
+ ///
+ ///
+ /// It cannot be : that handle also means "the queue has something in
+ /// it", which Emit signals constantly, so waiting on it during a connect would
+ /// abandon the attempt every time a hook fired. Two meanings, two handles.
+ ///
+ ///
+ private readonly ManualResetEvent _stopping = new ManualResetEvent(false);
+
private PluginConfig _config;
private Thread _link;
@@ -223,10 +254,19 @@ namespace Oxide.Plugins
private void Unload()
{
- // Best-effort, and it has to happen BEFORE `_running` goes false, because `Emit`
- // refuses to enqueue once it does. A tally lost here is one interval of one player's
- // gathering; flushing costs nothing and the ordering is the only reason it works at
- // all.
+ // Attempted before `_running` goes false, because `Emit` refuses to enqueue once it
+ // does — but be honest about what this achieves: the writer stops on the same flag a
+ // few lines below and the queue is cleared after the join, so a frame enqueued here
+ // only survives if the writer happens to be mid-drain. It usually is not.
+ //
+ // That is left as it is rather than fixed with a drain-before-exit, because draining
+ // means waiting on a socket from `Unload`, which runs on the MAIN THREAD — the exact
+ // stall phase 1 removed when a 1.9-second reload showed up as
+ // `Calling 'Unload' took 1918ms`. A wedged peer would make it worse than the bug.
+ //
+ // What is actually lost is under a minute of one player's gathering, and only on a
+ // manual plugin reload: a real shutdown flushes at `OnServerShutdown`, and a player
+ // leaving flushes at their disconnect.
try
{
FlushTallies();
@@ -243,6 +283,7 @@ namespace Oxide.Plugins
}
_running = false;
+ _stopping.Set();
Wake.Set();
Thread t = _link;
@@ -385,7 +426,7 @@ namespace Oxide.Plugins
client = new TcpClient();
client.NoDelay = true;
- client.Connect(host, port);
+ Connect(client, host, port);
NetworkStream stream = client.GetStream();
stream.WriteTimeout = 5000; // a wedged peer must surface as an error, not a hang
@@ -479,6 +520,48 @@ namespace Oxide.Plugins
_connected = false;
}
+ ///
+ /// Connects with a bound, and with a way out.
+ ///
+ ///
+ /// The wait ends on whichever comes first: the connect completing, the plugin being
+ /// unloaded, or . Without the middle one a reload against
+ /// an unreachable sidecar costs the game's main thread the whole join timeout —
+ /// observed as hook 'Unload' took longer than 100ms [2002ms] beside
+ /// link thread did not stop cleanly, which is phase 1's stall arriving by a
+ /// different road.
+ ///
+ ///
+ private void Connect(TcpClient client, string host, int port)
+ {
+ IAsyncResult pending = client.BeginConnect(host, port, null, null);
+
+ int signalled = WaitHandle.WaitAny(
+ new WaitHandle[] { pending.AsyncWaitHandle, _stopping }, ConnectTimeoutMs);
+
+ if (signalled != 0)
+ {
+ // Closing the client is what abandons the in-flight connect; the socket would
+ // otherwise stay open behind an IAsyncResult nobody is going to end.
+ try
+ {
+ client.Close();
+ }
+ catch
+ {
+ }
+
+ throw new System.IO.IOException(
+ signalled == 1
+ ? "unloading"
+ : $"connect to {host}:{port} timed out after {ConnectTimeoutMs}ms");
+ }
+
+ // Throws the real socket error — a refusal, an unknown host — which is what the caller
+ // logs. Only reached when the connect actually completed or failed on its own.
+ client.EndConnect(pending);
+ }
+
private void WriteLoop(NetworkStream stream)
{
while (_running && !_dead)
@@ -696,6 +779,12 @@ namespace Oxide.Plugins
{ "serverId", _config != null ? _config.ServerId : "main" }
};
+ // Resolved lazily while it is still unknown, which covers the case above on a
+ // framework that never fires `OnServerInitialized` — and costs one static property
+ // read per frame only until the answer exists.
+ if (string.IsNullOrEmpty(_wipeId))
+ _wipeId = ResolveWipeId();
+
if (!string.IsNullOrEmpty(_wipeId))
frame["wipeId"] = _wipeId;
@@ -1035,6 +1124,17 @@ namespace Oxide.Plugins
{
MarkHook("OnServerInitialized");
+ // Re-read the wipe, and this is not belt-and-braces: at a real server
+ // start `Init` runs BEFORE the save is loaded, so `SaveCreatedTime` is not yet
+ // meaningful and the id resolved there is null for the entire session. This hook is
+ // the first moment the world is actually there.
+ //
+ // It was invisible on a development rig for the reason such things usually are: a
+ // hot-reloaded plugin reads an already-loaded world and gets the right answer every
+ // time. It took a server that BOOTED with the plugin installed — which is every real
+ // one — to show `wipeId=none` on a server with a save sitting on disk.
+ _wipeId = ResolveWipeId();
+
// Started here rather than in Init: this hook fires both at the end of server startup
// and immediately on a hotload, so it is the one place that runs exactly once per
// load with the world available either way.