From 042727e5fe61f8978dc5c07bed8fb989a5f1e3fb Mon Sep 17 00:00:00 2001 From: wtclaude Date: Sat, 26 Sep 2026 17:04:41 -0500 Subject: [PATCH] =?UTF-8?q?fix(bridge):=20protocol=2013=20opens=20?= =?UTF-8?q?=E2=80=94=20UTF-8=20from=20the=20site,=20a=20reload=20that=20fa?= =?UTF-8?q?ils=20on=20evidence=20(F15,=20F9,=20F10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F15: the read loop cast each byte to a char, decoding the site's UTF-8 as Latin-1, so every non-ASCII announcement, title, kit name and saved config file arrived mangled. Lines are now split as bytes and decoded whole, which keeps a character that straddles two reads intact. F9/F10 (D177, D178): config.write answers at once with pending, writeId and ceilingMs, and the outcome follows as a config.outcome event. A pending reload reads the framework log each second and rolls back the moment the target fails to compile or initialise; a slow compile is waited for, under a 30 s ceiling per phase. At the ceiling the plugin looks at what is loaded before restoring anything, and a rollback waits for the restore's own reload so the outcome says whether the plugin is back. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- overlay.toml | 8 +- overlay/oxide/plugins/RunicGateway.cs | 629 ++++++++++++++++++++++---- 2 files changed, 551 insertions(+), 86 deletions(-) diff --git a/overlay.toml b/overlay.toml index 2ba4501..33a24ba 100644 --- a/overlay.toml +++ b/overlay.toml @@ -26,13 +26,15 @@ # the same change that alters the emitters, exactly as the sidecar bumps # PROTOCOL_VERSION and the module bumps its own constant. # -# Current: 12 — the transport, the read path, identity, the permission mirror, +# Current: 13 — the transport, the read path, identity, the permission mirror, # configuration from the site, first-party clans, a raid frame that names who # lives there, the event leases, the world verbs events place and give back, and # the rewards: a run's participation tally, kit credits and chat (PROTOCOL.md §16), # the map: its picture and everything that moves on it (PROTOCOL.md §17), and the -# optional mods: BetterChat titles and group styles, and popups (PROTOCOL.md §18). -protocol = 12 +# optional mods: BetterChat titles and group styles, and popups (PROTOCOL.md §18), +# and the first player walk's fixes (PROTOCOL.md §19) — opened by a configuration +# save that answers at once and reports its reload as a `config.outcome` frame. +protocol = 13 # ── Oxide compatibility ────────────────────────────────────────────────────── # diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index b8595ae..23082ac 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -57,7 +57,7 @@ namespace Oxide.Plugins /// overlay's overlay.toml carries this same number so the installer can refuse /// to pair a sidecar and an overlay that disagree. /// - private const int ProtocolVersion = 12; + private const int ProtocolVersion = 13; /// /// Loopback reconnects are cheap, so the ceiling is low. A sidecar restart should cost @@ -321,7 +321,8 @@ namespace Oxide.Plugins // A config write waiting on a reload dies here too, and its files stay as written. // That is the honest outcome: the rollback exists to undo an edit that took a plugin // down, and this plugin being unloaded is not evidence of either — it is usually THIS - // plugin being reloaded by somebody. The caller times out and re-reads. + // plugin being reloaded by somebody. No `config.outcome` follows; the website calls a + // write it never heard about lost once twice `ceilingMs` has passed, and re-reads. StopConfigWatch(); _configJob = null; @@ -768,10 +769,25 @@ namespace Oxide.Plugins } } + /// + /// Reader thread. Splits the stream into lines as bytes and decodes each finished + /// line as UTF-8. + /// + /// + /// Bytes, not characters, because a character can straddle two reads: the first half of + /// an em dash at the end of one buffer and the rest at the start of the next. Splitting + /// on the byte is safe where splitting the text is not — UTF-8 never uses 0x0A + /// inside a multi-byte sequence, so a newline byte is always a newline — and decoding + /// the whole line at once is what keeps the halves together. The old loop cast each byte + /// to a char, which is Latin-1, and turned every non-ASCII character the website + /// sent into two or three wrong ones: announcements, titles, kit names, and the text of + /// every configuration file saved from the site (F15). + /// + /// private void ReadLoop(NetworkStream stream, int epoch) { byte[] buffer = new byte[8192]; - StringBuilder line = new StringBuilder(512); + var line = new MemoryStream(512); bool discarding = false; try @@ -785,9 +801,9 @@ namespace Oxide.Plugins for (int i = 0; i < read; i++) { - char c = (char)buffer[i]; + byte b = buffer[i]; - if (c == '\n') + if (b == (byte)'\n') { if (discarding) { @@ -797,22 +813,22 @@ namespace Oxide.Plugins } else { - Dispatch(line.ToString()); + Dispatch(Encoding.UTF8.GetString(line.GetBuffer(), 0, (int)line.Length)); } - line.Length = 0; + line.SetLength(0); } - else if (c != '\r') + else if (b != (byte)'\r') { if (discarding) continue; - line.Append(c); + line.WriteByte(b); if (line.Length > MaxInboundLineBytes) { Puts("inbound line over the cap; discarding to the next newline"); - line.Length = 0; + line.SetLength(0); discarding = true; } } @@ -4072,23 +4088,66 @@ namespace Oxide.Plugins private const int ConfigMaxWriteFiles = 10; /// - /// How long a reload has to produce OnPluginLoaded before the write is undone. + /// The longest a reload may take before this plugin stops waiting for it (D178). /// /// - /// It is under half the sidecar's RPC timeout, and that is arithmetic rather than - /// taste. The worst path through one write is two of these windows — wait for the - /// edit's reload, give up, restore, wait for the restore's reload — with a caller - /// holding a socket for the whole of it (REPLY_TIMEOUT, 10s, rust-link - /// sidecar/src/rpc.rs). A window that does not leave room for both makes the - /// rollback report arrive after the only thing waiting to hear it has given up, which - /// is the one outcome worse than no rollback: the plugin is fine and nobody knows. + /// A ceiling, not the test. Protocol 12 rolled back any reload that had not + /// announced itself within four seconds, because the caller held a socket for the whole + /// wait. That window lost to work whose length nothing here controls: the org lead's + /// first walk saw Oxide's compiler idle out, restart, and load Kits 2.85 s later — just + /// past the window — and the plugin put a valid edit back (F9). Protocol 13 answers the + /// save at once and sends the outcome as a frame, so the wait no longer has to fit + /// inside an RPC, and a reload fails on evidence: the framework's log saying the + /// plugin did not compile or initialise (). This number + /// only bounds a reload that says nothing at all. + /// + /// + /// + /// Each phase gets it once — the edit's reload, and the restore's if there is one — and + /// the pending reply carries it as ceilingMs, so the website knows when a write it + /// never heard back about is lost rather than slow. /// /// - private const float ConfigReloadWindowSeconds = 4f; + private const float ConfigReloadCeilingSeconds = 30f; - /// How much of the newest log file is read back when a reload does not arrive. + /// How often a pending reload reads the log for its failure. + private const float ConfigLogPollSeconds = 1f; + + /// The most one poll reads from the log. A server spamming it cannot make a tick expensive. + private const int ConfigLogPollMaxBytes = 64 * 1024; + + /// How much of the newest log file is read back when a reload fails. private const int ConfigLogTailBytes = 8 * 1024; + /// + /// What a failed load says in the log, beside the plugin's name. + /// + /// + /// Lower case, matched case-insensitively. Oxide's are its compiler's and its loader's + /// own words ("Error while compiling", "Failed to initialize plugin", seen on the rig in + /// configuration step 4). Carbon's differ, and the rest of the list is there so that the + /// same file works on both (R19); the Carbon pass of the re-walk is what pins them. + /// + /// + private static readonly string[] ConfigFailureWords = + { + "error while compiling", + "failed to initialize", + "failed initializing", + "failed to compile", + "failed compiling", + "failed to load", + "failed loading", + "unable to load", + }; + + /// Which reload a job is waiting on: the edit's, or the restore's after a rollback. + private enum ConfigPhase + { + Edit, + Restore + } + /// One file, resolved and checked, on its way to disk. private sealed class StagedConfigWrite { @@ -4113,13 +4172,44 @@ namespace Oxide.Plugins /// private sealed class ConfigWriteJob { - public string ReqId; + /// + /// Ours, not the sidecar's reqId. The reply and the later + /// config.outcome both carry it, and the website files the outcome by it — + /// the sidecar's request ids restart with the sidecar, so they cannot name a write + /// across one. + /// + public string WriteId; + public string Reload; public List Staged; + + /// The one-second poll that reads the log and enforces the ceiling. public Timer Watch; - /// Whether the target was seen leaving. It separates "never started" from "did not come back". + public ConfigPhase Phase; + + /// When the current phase's reload was asked for. Each phase gets the whole ceiling. + public DateTime PhaseStarted; + + /// When the write landed, for the outcome's elapsedMs. + public DateTime Started; + + /// Whether the target was seen leaving in this phase. It separates "never started" from "did not come back". public bool Unloaded; + + /// The log file being read, and how far into it this job has read. + public string LogPath; + + public long LogOffset; + + /// A line cut off at the end of the last read, kept until the rest arrives. + public string LogPartial = ""; + + /// Why the edit was rolled back, carried into the outcome once the restore settles. + public string RollbackReason; + + /// The log lines that showed the failure, or the tail read when the ceiling passed. + public string RollbackLog; } private ConfigWriteJob _configJob; @@ -4530,25 +4620,41 @@ namespace Oxide.Plugins return; } - _configJob = new ConfigWriteJob + var job = new ConfigWriteJob { - ReqId = reqId, + WriteId = Guid.NewGuid().ToString("N"), Reload = reload, - Staged = staged + Staged = staged, + Phase = ConfigPhase.Edit, + Started = DateTime.UtcNow, + PhaseStarted = DateTime.UtcNow }; - _configJob.Watch = timer.Once(ConfigReloadWindowSeconds, ConfigReloadTimedOut); + // Where the log is NOW, before the reload writes anything to it. Only lines after this + // point can be about this reload. + MarkLogPosition(job); + + _configJob = job; if (!ReloadPluginByName(reload)) { - StopConfigWatch(); _configJob = null; RestoreConfig(staged); EmitConfigError(reqId, "reload-failed", $"this framework refused to reload '{reload}'"); + return; } + + // The reload was asked for and has not finished; it may be seconds away behind a cold + // compile. Answer now — the caller is holding a socket with a ten-second budget — and + // send the outcome as a frame when there is one (F9, D177). + job.Watch = timer.Every(ConfigLogPollSeconds, ConfigWatchTick); + SendConfigReport(reqId, staged, false, false, null, null, job); } - /// Main thread. The reload arrived: the plugin is back, so the write stands. + /// + /// Main thread. OnPluginLoaded named the target: in the edit phase the write + /// stands; in the restore phase the plugin is back on its old file. + /// private void ConfigReloadArrived() { ConfigWriteJob job = _configJob; @@ -4556,42 +4662,144 @@ namespace Oxide.Plugins if (job == null) return; - StopConfigWatch(); - _configJob = null; - - SendConfigReport(job.ReqId, job.Staged, true, false, null, null); + if (job.Phase == ConfigPhase.Edit) + SettleConfigJob(job, true, false, null, null, null); + else + SettleConfigJob(job, false, true, true, job.RollbackReason, job.RollbackLog); } /// - /// Main thread. The window closed with no OnPluginLoaded, which means the plugin - /// is down and the edit is why. Put every file back, reload again, and say so. + /// Main thread, once a second while a reload is pending. Reads what the framework has + /// logged since the last tick, and enforces the ceiling. /// /// - /// This is the feature's real content. Without it, R18 is a web form that takes a - /// required plugin off a production server one typo at a time. + /// A failure is a line, not a silence (D178). Both frameworks log the moment a + /// plugin fails to compile or to initialise, so a broken edit is rolled back about a + /// second after it breaks, and a slow compile — which logs nothing until it succeeds — + /// is simply waited for. The ceiling is for a reload that says nothing either way. + /// + /// + /// + /// What the ceiling does depends on what is loaded, not on the clock (F10). + /// Protocol 12 restored the files whenever the window closed, even over a plugin that + /// was about to finish loading the edit, and then reported "did not come back" about a + /// plugin that did. Now: a target that is loaded again was reloaded and its announcement + /// missed, so the edit stands; one that is loaded and was never seen unloading was never + /// reloaded at all, so the file stands and applies at its next load; only a target that + /// is not loaded has its files put back. /// /// - private void ConfigReloadTimedOut() + private void ConfigWatchTick() { ConfigWriteJob job = _configJob; if (job == null) + { + return; + } + + string failure = ReadConfigFailure(job); + + if (failure != null) + { + if (job.Phase == ConfigPhase.Edit) + { + RollBackConfig(job, $"'{job.Reload}' failed to load: {ShortLine(failure)}", failure); + } + else + { + SettleConfigJob(job, false, true, false, + $"{job.RollbackReason}; the restored file did not load either: {ShortLine(failure)}", + job.RollbackLog + "\n" + failure); + } + + return; + } + + if ((DateTime.UtcNow - job.PhaseStarted).TotalSeconds < ConfigReloadCeilingSeconds) return; - _configJob = null; - StopConfigWatch(); + bool loaded = IsPluginLoaded(job.Reload); + if (job.Phase == ConfigPhase.Restore) + { + SettleConfigJob(job, false, true, loaded, + loaded + ? job.RollbackReason + : $"{job.RollbackReason}; '{job.Reload}' did not come back on the restored file " + + $"within {ConfigReloadCeilingSeconds:0}s either", + job.RollbackLog); + return; + } + + if (loaded && job.Unloaded) + { + SettleConfigJob(job, true, false, null, + $"'{job.Reload}' is loaded again, but its load was not announced", null); + } + else if (loaded) + { + SettleConfigJob(job, false, false, null, + $"the framework did not reload '{job.Reload}' within {ConfigReloadCeilingSeconds:0}s; " + + "the file stands and applies at its next load", + null); + } + else + { + RollBackConfig(job, + job.Unloaded + ? $"'{job.Reload}' unloaded and did not come back within {ConfigReloadCeilingSeconds:0}s" + : $"'{job.Reload}' did not load within {ConfigReloadCeilingSeconds:0}s", + LogTail()); + } + } + + /// + /// Main thread. The edit took the plugin down: put every file back and reload again. + /// + /// + /// This is the feature's real content. Without it, R18 is a web form that takes a + /// required plugin off a production server one typo at a time. The outcome waits for the + /// restore's own reload, so it can say whether the plugin is back, rather than only that + /// the files are. + /// + /// + private void RollBackConfig(ConfigWriteJob job, string reason, string log) + { RestoreConfig(job.Staged); - string reason = job.Unloaded - ? $"'{job.Reload}' unloaded and did not come back within {ConfigReloadWindowSeconds:0}s" - : $"'{job.Reload}' did not reload within {ConfigReloadWindowSeconds:0}s"; + job.Phase = ConfigPhase.Restore; + job.PhaseStarted = DateTime.UtcNow; + job.Unloaded = false; + job.RollbackReason = reason; + job.RollbackLog = log; - // Fire and forget: the restore is on disk either way, and waiting for a second window - // would spend the caller's whole RPC budget to learn something the operator can see. - ReloadPluginByName(job.Reload); + // The restore's failure, if it has one, is logged after this point. + MarkLogPosition(job); - SendConfigReport(job.ReqId, job.Staged, false, true, reason, LogTail()); + if (!ReloadPluginByName(job.Reload)) + { + SettleConfigJob(job, false, true, false, + $"{reason}; the files are back, but this framework refused to reload '{job.Reload}'", log); + } + } + + /// Main thread. The job is over: stop watching and say how it ended. + private void SettleConfigJob( + ConfigWriteJob job, + bool reloaded, + bool rolledBack, + bool? restored, + string reason, + string log) + { + if (_configJob != job) + return; + + StopConfigWatch(); + _configJob = null; + + EmitConfigOutcome(job, reloaded, rolledBack, restored, reason, log); } private void StopConfigWatch() @@ -4605,8 +4813,172 @@ namespace Oxide.Plugins job.Watch = null; } + private bool IsPluginLoaded(string name) + { + Plugin found = plugins.Find(name); + return found != null; + } + + /// Main thread. Points the job at the end of the newest log file. + private void MarkLogPosition(ConfigWriteJob job) + { + job.LogPath = NewestLogFile(); + job.LogPartial = ""; + + try + { + job.LogOffset = job.LogPath != null ? new FileInfo(job.LogPath).Length : 0; + } + catch (Exception) + { + job.LogOffset = 0; + } + } + /// - /// Main thread. What happened, and what is on disk now. + /// Main thread. The first line logged since the last read that says the target failed to + /// load, or null. + /// + /// + /// Bounded per call. A log that rotates mid-job (midnight) is followed to the new file + /// from its start; one that shrank was truncated and is read again from the top. Lines + /// this plugin wrote itself are skipped, so a message of ours that names the target + /// beside the word "failed" cannot roll back a reload that is going fine. + /// + /// + private string ReadConfigFailure(ConfigWriteJob job) + { + try + { + string newest = NewestLogFile(); + + if (newest == null) + return null; + + if (!string.Equals(newest, job.LogPath, StringComparison.OrdinalIgnoreCase)) + { + job.LogPath = newest; + job.LogOffset = 0; + job.LogPartial = ""; + } + + string text; + + using (var stream = new FileStream(job.LogPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + { + if (stream.Length < job.LogOffset) + { + job.LogOffset = 0; + job.LogPartial = ""; + } + + long available = stream.Length - job.LogOffset; + + if (available <= 0) + return null; + + int take = (int)Math.Min(available, ConfigLogPollMaxBytes); + stream.Seek(job.LogOffset, SeekOrigin.Begin); + + var buffer = new byte[take]; + int read = stream.Read(buffer, 0, take); + job.LogOffset += read; + + text = job.LogPartial + Encoding.UTF8.GetString(buffer, 0, read); + } + + int lastBreak = text.LastIndexOf('\n'); + + if (lastBreak < 0) + { + job.LogPartial = text.Length > ConfigLogPollMaxBytes ? "" : text; + return null; + } + + job.LogPartial = text.Substring(lastBreak + 1); + + if (job.LogPartial.Length > ConfigLogPollMaxBytes) + job.LogPartial = ""; + + string own = "[" + Title + "]"; + + foreach (string raw in text.Substring(0, lastBreak).Split('\n')) + { + string line = raw.TrimEnd('\r'); + + if (line.IndexOf(own, StringComparison.OrdinalIgnoreCase) >= 0) + continue; + + if (IsLoadFailureOf(line, job.Reload)) + return line; + } + } + catch (Exception) + { + // A log that cannot be read costs the fast path and nothing else: the ceiling + // still settles the job. + } + + return null; + } + + /// Whether a log line says failed to compile or to initialise. + private static bool IsLoadFailureOf(string line, string plugin) + { + if (string.IsNullOrEmpty(line) || string.IsNullOrEmpty(plugin)) + return false; + + string lower = line.ToLowerInvariant(); + bool failed = false; + + foreach (string word in ConfigFailureWords) + { + if (lower.IndexOf(word, StringComparison.Ordinal) >= 0) + { + failed = true; + break; + } + } + + return failed && MentionsName(lower, plugin.ToLowerInvariant()); + } + + /// + /// Whether appears as a whole name — so a failure of + /// KitsExtended is not read as one of Kits. A trailing .cs or a + /// version still counts: "Kits.cs", "'Kits v4.4.9'". + /// + private static bool MentionsName(string lower, string name) + { + int from = 0; + + while (true) + { + int at = lower.IndexOf(name, from, StringComparison.Ordinal); + + if (at < 0) + return false; + + int end = at + name.Length; + bool startOk = at == 0 || !char.IsLetterOrDigit(lower[at - 1]); + bool endOk = end == lower.Length || !char.IsLetterOrDigit(lower[end]); + + if (startOk && endOk) + return true; + + from = at + 1; + } + } + + /// A log line short enough for a reason sentence. + private static string ShortLine(string line) + { + string trimmed = (line ?? "").Trim(); + return trimmed.Length <= 240 ? trimmed : trimmed.Substring(0, 240) + "…"; + } + + /// + /// Main thread. Each file as it is on disk now. /// /// /// The versions are re-read rather than assumed, because both frameworks merge missing @@ -4615,13 +4987,7 @@ namespace Oxide.Plugins /// conflict with itself on the next save. /// /// - private void SendConfigReport( - string reqId, - List staged, - bool reloaded, - bool rolledBack, - string reason, - string log) + private List ConfigFilesNow(List staged, bool rolledBack) { var files = new List(); @@ -4644,11 +5010,40 @@ namespace Oxide.Plugins files.Add(row); } + return files; + } + + /// + /// Main thread. The reply to config.write. + /// + /// + /// With a job it says "written, reloading": pending, + /// the writeId the outcome will carry, and ceilingMs. Without one it is the + /// whole answer — nothing was asked to reload, or the target was not loaded. + /// + /// + private void SendConfigReport( + string reqId, + List staged, + bool reloaded, + bool rolledBack, + string reason, + string log, + ConfigWriteJob pending = null) + { Dictionary report = Frame("config.report", "reply"); report["ok"] = !rolledBack; report["reloaded"] = reloaded; report["rolledBack"] = rolledBack; - report["files"] = files; + report["pending"] = pending != null; + report["files"] = ConfigFilesNow(staged, rolledBack); + + if (pending != null) + { + report["writeId"] = pending.WriteId; + report["reload"] = pending.Reload; + report["ceilingMs"] = (long)(ConfigReloadCeilingSeconds * 1000); + } if (!string.IsNullOrEmpty(reason)) report["reason"] = reason; @@ -4659,14 +5054,59 @@ namespace Oxide.Plugins if (reqId != null) report["reqId"] = reqId; - _lastConfigSummary = - $"files={staged.Count} reloaded={reloaded} rolledBack={rolledBack} " + - $"at={DateTime.UtcNow:HH:mm:ss}Z" + - (string.IsNullOrEmpty(reason) ? "" : $" reason={reason}"); + _lastConfigSummary = pending != null + ? $"files={staged.Count} reloading={pending.Reload} at={DateTime.UtcNow:HH:mm:ss}Z" + : $"files={staged.Count} reloaded={reloaded} rolledBack={rolledBack} " + + $"at={DateTime.UtcNow:HH:mm:ss}Z" + + (string.IsNullOrEmpty(reason) ? "" : $" reason={reason}"); EmitFrame(report); } + /// + /// Main thread. How a pending reload ended, as an event (protocol 13, F9). + /// + /// + /// An event, not a reply, because nothing is waiting for it: the sidecar files it in its + /// feed and the website's ingest settles the write it names. Staff class on the site — + /// the log tail is an operator's console. + /// + /// + private void EmitConfigOutcome( + ConfigWriteJob job, + bool reloaded, + bool rolledBack, + bool? restored, + string reason, + string log) + { + Dictionary frame = Frame("config.outcome", "event"); + frame["writeId"] = job.WriteId; + frame["reload"] = job.Reload; + frame["ok"] = !rolledBack; + frame["reloaded"] = reloaded; + frame["rolledBack"] = rolledBack; + frame["elapsedMs"] = (long)(DateTime.UtcNow - job.Started).TotalMilliseconds; + frame["files"] = ConfigFilesNow(job.Staged, rolledBack); + + if (restored.HasValue) + frame["restored"] = restored.Value; + + if (!string.IsNullOrEmpty(reason)) + frame["reason"] = reason; + + if (!string.IsNullOrEmpty(log)) + frame["log"] = log; + + _lastConfigSummary = + $"files={job.Staged.Count} reloaded={reloaded} rolledBack={rolledBack}" + + (restored.HasValue ? $" restored={restored.Value}" : "") + + $" at={DateTime.UtcNow:HH:mm:ss}Z" + + (string.IsNullOrEmpty(reason) ? "" : $" reason={reason}"); + + EmitFrame(frame); + } + private void EmitConfigError(string reqId, string reason, string detail) { Dictionary frame = Frame("config.error", "reply"); @@ -4811,6 +5251,42 @@ namespace Oxide.Plugins } } + /// + /// The framework's newest log file, or null. The framework's own log is the newest + /// *.txt directly in LogDirectory; plugins that log to files write into + /// subdirectories of it, which this does not look in. + /// + private static string NewestLogFile() + { + try + { + string dir = Interface.Oxide.LogDirectory; + + if (string.IsNullOrEmpty(dir) || !Directory.Exists(dir)) + return null; + + string newest = null; + DateTime newestAt = DateTime.MinValue; + + foreach (string candidate in Directory.GetFiles(dir, "*.txt")) + { + DateTime at = File.GetLastWriteTimeUtc(candidate); + + if (at <= newestAt) + continue; + + newest = candidate; + newestAt = at; + } + + return newest; + } + catch (Exception) + { + return null; + } + } + /// /// The tail of the newest log file, read only when a reload failed. /// @@ -4824,29 +5300,7 @@ namespace Oxide.Plugins { try { - string dir = Interface.Oxide.LogDirectory; - - if (string.IsNullOrEmpty(dir) || !Directory.Exists(dir)) - return null; - - string[] logs = Directory.GetFiles(dir, "*.txt"); - - if (logs.Length == 0) - return null; - - string newest = null; - DateTime newestAt = DateTime.MinValue; - - foreach (string candidate in logs) - { - DateTime at = File.GetLastWriteTimeUtc(candidate); - - if (at <= newestAt) - continue; - - newest = candidate; - newestAt = at; - } + string newest = NewestLogFile(); if (newest == null) return null; @@ -10279,11 +10733,20 @@ namespace Oxide.Plugins private void CmdConfig(ConsoleSystem.Arg arg) { string root = ConfigRoot(); + ConfigWriteJob job = _configJob; + string waiting = "no"; + + if (job != null) + { + double seconds = (DateTime.UtcNow - job.PhaseStarted).TotalSeconds; + waiting = $"{job.Reload} ({job.Phase.ToString().ToLowerInvariant()}, " + + $"{seconds:0}s of {ConfigReloadCeilingSeconds:0}s)"; + } arg.ReplyWith( $"protocol={ProtocolVersion} framework={(IsCarbon() ? "carbon" : "oxide")} " + $"root={(string.IsNullOrEmpty(root) ? "(none)" : root)}" + Environment.NewLine + - $"waiting={(_configJob != null ? _configJob.Reload : "no")}" + Environment.NewLine + + $"waiting={waiting}" + Environment.NewLine + $"last write: {_lastConfigSummary}"); } -- 2.49.1