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