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.