From 04491e70af4364012930deb1e745b02a3ffd943c Mon Sep 17 00:00:00 2001 From: wtclaude Date: Sat, 26 Sep 2026 01:07:41 -0500 Subject: [PATCH 1/2] fix(bridge): read the config before the link thread dials (D154) Init() started LinkLoop before Loaded() read the config, so the thread's first dial saw _config == null and used the 7799 fallback on both Oxide and Carbon. One server per host made that its own sidecar by coincidence. With two on a host (the installer's named instances, phase 18), the second server dialled the FIRST server's sidecar, sat in its accept backlog, and the moment the first server's plugin reloaded that sidecar took the second server's connection: the website showed server "alpha" with beta's hostname and wipe. LoadConfigValues() now runs in Init() before the thread starts; Loaded() keeps its log line and the lease/world/reward loads. Walked on the phase 18 rig: three instances (two Oxide, one Carbon 7801), each now dials its own port. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- overlay/oxide/plugins/RunicGateway.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index d16d02e..fd998f3 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -244,6 +244,13 @@ namespace Oxide.Plugins _bootId = ResolveBootId(); _wipeId = ResolveWipeId(); + + // Before the link thread, not in `Loaded`: both frameworks call `Init` first, and a + // thread started here with `_config` still null dials the 7799 fallback. With one + // server per host that is its own sidecar by coincidence; with two it is the OTHER + // server's, whose sidecar then files this server's history under that one's id + // (found by the phase 18 walk, D154). + LoadConfigValues(); _running = true; // Expired link codes are dropped on a timer, not on lookup: an unconfirmed code is @@ -496,7 +503,6 @@ namespace Oxide.Plugins private void Loaded() { - LoadConfigValues(); Puts($"protocol {ProtocolVersion}, serverId '{_config.ServerId}', sidecar {_config.Host}:{_config.Port}"); // Here rather than at `OnServerInitialized`: the link thread starts in `Init`, so a From 3e928bb2f6f61c50180b3644cb6ee64893f21e58 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Sat, 26 Sep 2026 01:19:23 -0500 Subject: [PATCH 2/2] fix(bridge): a backoff that holds against a sidecar that refuses (D155) The reconnect backoff waited on `Wake`, which `Emit` signals on every frame, so it lasted only until the next hook fired. That was invisible while a failed connect was silent; against a sidecar that now refuses a plugin naming another server (D155) the phase 18 walk measured some 250 redials a second, each printing two console lines and rebuilding the boards. It now waits on `_stopping`, the handle that already exists so a CONNECT is not abandoned by `Emit`; Unload still ends the wait at once. The backoff also resets only after a link has held for 10 s (StableLinkMs). A connection closed on arrival used to reset it to 500 ms on every accept, so a refusal loop never backed off at all. Walked: a plugin aimed at another server's sidecar is refused every 5 s (the ceiling), not continuously; a stable link whose sidecar restarts is back in 0.49 s, as before; no Unload stall on reload. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- overlay/oxide/plugins/RunicGateway.cs | 35 +++++++++++++++++++++------ 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index fd998f3..b8595ae 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -65,6 +65,15 @@ namespace Oxide.Plugins /// private const int MaxBackoffMs = 5000; + /// + /// How long a link must stay up before its drop counts as an outage rather than a + /// refusal. The backoff resets only past it: a sidecar that closes every connection at + /// once — one refusing a plugin that names another server (D155), or one crash-looping — + /// would otherwise be redialled twice a second for ever, rebuilding the boards on the + /// main thread and printing two console lines each time. + /// + private const int StableLinkMs = 10000; + /// The longest line accepted from the sidecar. Over-long lines are discarded. private const int MaxInboundLineBytes = 1024 * 1024; @@ -570,6 +579,7 @@ namespace Oxide.Plugins { TcpClient client = null; Thread reader = null; + System.Diagnostics.Stopwatch upFor = null; int epoch = Interlocked.Increment(ref _epoch); @@ -588,7 +598,7 @@ namespace Oxide.Plugins _dead = false; _connected = true; _loggedConnectFailure = false; // so a LATER outage is reported again - backoffMs = 500; + upFor = System.Diagnostics.Stopwatch.StartNew(); Interlocked.Increment(ref _connects); Puts($"connected to {host}:{port}"); @@ -660,14 +670,25 @@ namespace Oxide.Plugins if (!_running) break; + // A link that held is an outage: start again from the shortest wait. One that was + // closed on arrival keeps doubling (see StableLinkMs). + if (upFor != null && upFor.ElapsedMilliseconds >= StableLinkMs) + backoffMs = 500; + // 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); + // A handle, 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 `_stopping`, so waiting on it turns that stall into + // an immediate return. + // + // `_stopping`, not `Wake`, for the reason `_stopping` gives: `Emit` signals `Wake` + // on every frame, so a backoff waited on it lasted until the next hook fired. That + // was invisible while a failed connect was silent; against a sidecar that refuses + // (D155) the phase 18 walk measured some 250 redials a second, each printing two + // console lines and rebuilding the boards. + _stopping.WaitOne(backoffMs); backoffMs = Math.Min(backoffMs * 2, MaxBackoffMs); }