fix(bridge): protocol 13 opens — UTF-8 from the site, a reload that fails on evidence (F15, F9, F10) #16
@@ -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 ──────────────────────────────────────────────────────
|
||||
#
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user