fix(bridge): protocol 13 opens — UTF-8 from the site, a reload that fails on evidence (F15, F9, F10) #16

Merged
whitlocktech merged 1 commits from fix/protocol-13-config-reload into edge 2026-09-26 17:22:11 -05:00
2 changed files with 551 additions and 86 deletions

View File

@@ -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 ──────────────────────────────────────────────────────
#

View File

@@ -57,7 +57,7 @@ namespace Oxide.Plugins
/// 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 = 12;
private const int ProtocolVersion = 13;
/// <summary>
/// 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
}
}
/// <summary>
/// Reader thread. Splits the stream into lines as <b>bytes</b> and decodes each finished
/// line as UTF-8.
///
/// <para>
/// 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 <c>0x0A</c>
/// 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 <c>char</c>, 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).
/// </para>
/// </summary>
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;
/// <summary>
/// How long a reload has to produce <c>OnPluginLoaded</c> before the write is undone.
/// The longest a reload may take before this plugin stops waiting for it (D178).
///
/// <para>
/// <b>It is under half the sidecar's RPC timeout, and that is arithmetic rather than
/// taste.</b> 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 (<c>REPLY_TIMEOUT</c>, 10s, rust-link
/// <c>sidecar/src/rpc.rs</c>). 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.
/// <b>A ceiling, not the test.</b> 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 <b>evidence</b>: the framework's log saying the
/// plugin did not compile or initialise (<see cref="ConfigWatchTick"/>). This number
/// only bounds a reload that says nothing at all.
/// </para>
///
/// <para>
/// Each phase gets it once — the edit's reload, and the restore's if there is one — and
/// the pending reply carries it as <c>ceilingMs</c>, so the website knows when a write it
/// never heard back about is lost rather than slow.
/// </para>
/// </summary>
private const float ConfigReloadWindowSeconds = 4f;
private const float ConfigReloadCeilingSeconds = 30f;
/// <summary>How much of the newest log file is read back when a reload does not arrive.</summary>
/// <summary>How often a pending reload reads the log for its failure.</summary>
private const float ConfigLogPollSeconds = 1f;
/// <summary>The most one poll reads from the log. A server spamming it cannot make a tick expensive.</summary>
private const int ConfigLogPollMaxBytes = 64 * 1024;
/// <summary>How much of the newest log file is read back when a reload fails.</summary>
private const int ConfigLogTailBytes = 8 * 1024;
/// <summary>
/// What a failed load says in the log, beside the plugin's name.
///
/// <para>
/// 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.
/// </para>
/// </summary>
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",
};
/// <summary>Which reload a job is waiting on: the edit's, or the restore's after a rollback.</summary>
private enum ConfigPhase
{
Edit,
Restore
}
/// <summary>One file, resolved and checked, on its way to disk.</summary>
private sealed class StagedConfigWrite
{
@@ -4113,13 +4172,44 @@ namespace Oxide.Plugins
/// </summary>
private sealed class ConfigWriteJob
{
public string ReqId;
/// <summary>
/// Ours, not the sidecar's <c>reqId</c>. The reply and the later
/// <c>config.outcome</c> 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.
/// </summary>
public string WriteId;
public string Reload;
public List<StagedConfigWrite> Staged;
/// <summary>The one-second poll that reads the log and enforces the ceiling.</summary>
public Timer Watch;
/// <summary>Whether the target was seen leaving. It separates "never started" from "did not come back".</summary>
public ConfigPhase Phase;
/// <summary>When the current phase's reload was asked for. Each phase gets the whole ceiling.</summary>
public DateTime PhaseStarted;
/// <summary>When the write landed, for the outcome's <c>elapsedMs</c>.</summary>
public DateTime Started;
/// <summary>Whether the target was seen leaving in this phase. It separates "never started" from "did not come back".</summary>
public bool Unloaded;
/// <summary>The log file being read, and how far into it this job has read.</summary>
public string LogPath;
public long LogOffset;
/// <summary>A line cut off at the end of the last read, kept until the rest arrives.</summary>
public string LogPartial = "";
/// <summary>Why the edit was rolled back, carried into the outcome once the restore settles.</summary>
public string RollbackReason;
/// <summary>The log lines that showed the failure, or the tail read when the ceiling passed.</summary>
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);
}
/// <summary>Main thread. The reload arrived: the plugin is back, so the write stands.</summary>
/// <summary>
/// Main thread. <c>OnPluginLoaded</c> named the target: in the edit phase the write
/// stands; in the restore phase the plugin is back on its old file.
/// </summary>
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);
}
/// <summary>
/// Main thread. The window closed with no <c>OnPluginLoaded</c>, 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.
///
/// <para>
/// 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.
/// <b>A failure is a line, not a silence (D178).</b> 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.
/// </para>
///
/// <para>
/// <b>What the ceiling does depends on what is loaded, not on the clock (F10).</b>
/// 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.
/// </para>
/// </summary>
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());
}
}
/// <summary>
/// Main thread. The edit took the plugin down: put every file back and reload again.
///
/// <para>
/// 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.
/// </para>
/// </summary>
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);
}
}
/// <summary>Main thread. The job is over: stop watching and say how it ended.</summary>
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;
}
/// <summary>Main thread. Points the job at the end of the newest log file.</summary>
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;
}
}
/// <summary>
/// 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.
///
/// <para>
/// 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.
/// </para>
/// </summary>
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;
}
/// <summary>Whether a log line says <paramref name="plugin"/> failed to compile or to initialise.</summary>
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());
}
/// <summary>
/// Whether <paramref name="name"/> appears as a whole name — so a failure of
/// <c>KitsExtended</c> is not read as one of <c>Kits</c>. A trailing <c>.cs</c> or a
/// version still counts: "Kits.cs", "'Kits v4.4.9'".
/// </summary>
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;
}
}
/// <summary>A log line short enough for a reason sentence.</summary>
private static string ShortLine(string line)
{
string trimmed = (line ?? "").Trim();
return trimmed.Length <= 240 ? trimmed : trimmed.Substring(0, 240) + "…";
}
/// <summary>
/// Main thread. Each file as it is on disk now.
///
/// <para>
/// 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.
/// </para>
/// </summary>
private void SendConfigReport(
string reqId,
List<StagedConfigWrite> staged,
bool reloaded,
bool rolledBack,
string reason,
string log)
private List<object> ConfigFilesNow(List<StagedConfigWrite> staged, bool rolledBack)
{
var files = new List<object>();
@@ -4644,11 +5010,40 @@ namespace Oxide.Plugins
files.Add(row);
}
return files;
}
/// <summary>
/// Main thread. The reply to <c>config.write</c>.
///
/// <para>
/// With a <paramref name="pending"/> job it says "written, reloading": <c>pending</c>,
/// the <c>writeId</c> the outcome will carry, and <c>ceilingMs</c>. Without one it is the
/// whole answer — nothing was asked to reload, or the target was not loaded.
/// </para>
/// </summary>
private void SendConfigReport(
string reqId,
List<StagedConfigWrite> staged,
bool reloaded,
bool rolledBack,
string reason,
string log,
ConfigWriteJob pending = null)
{
Dictionary<string, object> 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);
}
/// <summary>
/// Main thread. How a pending reload ended, as an event (protocol 13, F9).
///
/// <para>
/// 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.
/// </para>
/// </summary>
private void EmitConfigOutcome(
ConfigWriteJob job,
bool reloaded,
bool rolledBack,
bool? restored,
string reason,
string log)
{
Dictionary<string, object> 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<string, object> frame = Frame("config.error", "reply");
@@ -4811,6 +5251,42 @@ namespace Oxide.Plugins
}
}
/// <summary>
/// The framework's newest log file, or null. The framework's own log is the newest
/// <c>*.txt</c> directly in <c>LogDirectory</c>; plugins that log to files write into
/// subdirectories of it, which this does not look in.
/// </summary>
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;
}
}
/// <summary>
/// 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}");
}