feat(bridge): protocol 2 — the read path, and the first CI this repo has had #2

Merged
whitlocktech merged 2 commits from feat/phase-3-read-path into main 2026-09-16 16:36:59 +00:00
Showing only changes of commit 551359c08e - Show all commits

View File

@@ -67,6 +67,25 @@ namespace Oxide.Plugins
/// <summary>The longest line accepted from the sidecar. Over-long lines are discarded.</summary> /// <summary>The longest line accepted from the sidecar. Over-long lines are discarded.</summary>
private const int MaxInboundLineBytes = 1024 * 1024; private const int MaxInboundLineBytes = 1024 * 1024;
/// <summary>
/// How long to wait for the TCP connect itself before giving up and backing off.
///
/// <para>
/// It exists because <c>TcpClient.Connect</c> is blocking and has no timeout of its
/// own, and the OS default is tens of seconds. A host that REFUSES answers instantly —
/// which is every loopback test, and why this was invisible for two phases — but a host
/// that <b>drops</b> does not answer at all: a firewall, a wrong address, a machine
/// that is off. The link thread then sits in the connect, deaf to everything, and
/// <c>Unload</c> waits out its whole join on the game's MAIN THREAD.
/// </para>
///
/// <para>
/// Generous against any real network and short against a hang. Loopback, which is the
/// supported deployment, connects in well under a millisecond.
/// </para>
/// </summary>
private const int ConnectTimeoutMs = 3000;
/// <summary> /// <summary>
/// How often the boards are re-sent (PROTOCOL.md §8.3) and the per-player tallies are /// How often the boards are re-sent (PROTOCOL.md §8.3) and the per-player tallies are
/// flushed (§8.6). One cadence serves both because both answer the same question — /// flushed (§8.6). One cadence serves both because both answer the same question —
@@ -84,6 +103,18 @@ namespace Oxide.Plugins
private static readonly ConcurrentQueue<string> Outbound = new ConcurrentQueue<string>(); private static readonly ConcurrentQueue<string> Outbound = new ConcurrentQueue<string>();
private static readonly AutoResetEvent Wake = new AutoResetEvent(false); private static readonly AutoResetEvent Wake = new AutoResetEvent(false);
/// <summary>
/// Set once, by <c>Unload</c>, and never reset. It is what lets a thread blocked on a
/// CONNECT give up immediately instead of at the end of its timeout.
///
/// <para>
/// It cannot be <see cref="Wake"/>: that handle also means "the queue has something in
/// it", which <c>Emit</c> signals constantly, so waiting on it during a connect would
/// abandon the attempt every time a hook fired. Two meanings, two handles.
/// </para>
/// </summary>
private readonly ManualResetEvent _stopping = new ManualResetEvent(false);
private PluginConfig _config; private PluginConfig _config;
private Thread _link; private Thread _link;
@@ -223,10 +254,19 @@ namespace Oxide.Plugins
private void Unload() private void Unload()
{ {
// Best-effort, and it has to happen BEFORE `_running` goes false, because `Emit` // Attempted before `_running` goes false, because `Emit` refuses to enqueue once it
// refuses to enqueue once it does. A tally lost here is one interval of one player's // does — but be honest about what this achieves: the writer stops on the same flag a
// gathering; flushing costs nothing and the ordering is the only reason it works at // few lines below and the queue is cleared after the join, so a frame enqueued here
// all. // only survives if the writer happens to be mid-drain. It usually is not.
//
// That is left as it is rather than fixed with a drain-before-exit, because draining
// means waiting on a socket from `Unload`, which runs on the MAIN THREAD — the exact
// stall phase 1 removed when a 1.9-second reload showed up as
// `Calling 'Unload' took 1918ms`. A wedged peer would make it worse than the bug.
//
// What is actually lost is under a minute of one player's gathering, and only on a
// manual plugin reload: a real shutdown flushes at `OnServerShutdown`, and a player
// leaving flushes at their disconnect.
try try
{ {
FlushTallies(); FlushTallies();
@@ -243,6 +283,7 @@ namespace Oxide.Plugins
} }
_running = false; _running = false;
_stopping.Set();
Wake.Set(); Wake.Set();
Thread t = _link; Thread t = _link;
@@ -385,7 +426,7 @@ namespace Oxide.Plugins
client = new TcpClient(); client = new TcpClient();
client.NoDelay = true; client.NoDelay = true;
client.Connect(host, port); Connect(client, host, port);
NetworkStream stream = client.GetStream(); NetworkStream stream = client.GetStream();
stream.WriteTimeout = 5000; // a wedged peer must surface as an error, not a hang stream.WriteTimeout = 5000; // a wedged peer must surface as an error, not a hang
@@ -479,6 +520,48 @@ namespace Oxide.Plugins
_connected = false; _connected = false;
} }
/// <summary>
/// Connects with a bound, and with a way out.
///
/// <para>
/// The wait ends on whichever comes first: the connect completing, the plugin being
/// unloaded, or <see cref="ConnectTimeoutMs"/>. Without the middle one a reload against
/// an unreachable sidecar costs the game's main thread the whole join timeout —
/// observed as <c>hook 'Unload' took longer than 100ms [2002ms]</c> beside
/// <c>link thread did not stop cleanly</c>, which is phase 1's stall arriving by a
/// different road.
/// </para>
/// </summary>
private void Connect(TcpClient client, string host, int port)
{
IAsyncResult pending = client.BeginConnect(host, port, null, null);
int signalled = WaitHandle.WaitAny(
new WaitHandle[] { pending.AsyncWaitHandle, _stopping }, ConnectTimeoutMs);
if (signalled != 0)
{
// Closing the client is what abandons the in-flight connect; the socket would
// otherwise stay open behind an IAsyncResult nobody is going to end.
try
{
client.Close();
}
catch
{
}
throw new System.IO.IOException(
signalled == 1
? "unloading"
: $"connect to {host}:{port} timed out after {ConnectTimeoutMs}ms");
}
// Throws the real socket error — a refusal, an unknown host — which is what the caller
// logs. Only reached when the connect actually completed or failed on its own.
client.EndConnect(pending);
}
private void WriteLoop(NetworkStream stream) private void WriteLoop(NetworkStream stream)
{ {
while (_running && !_dead) while (_running && !_dead)
@@ -696,6 +779,12 @@ namespace Oxide.Plugins
{ "serverId", _config != null ? _config.ServerId : "main" } { "serverId", _config != null ? _config.ServerId : "main" }
}; };
// Resolved lazily while it is still unknown, which covers the case above on a
// framework that never fires `OnServerInitialized` — and costs one static property
// read per frame only until the answer exists.
if (string.IsNullOrEmpty(_wipeId))
_wipeId = ResolveWipeId();
if (!string.IsNullOrEmpty(_wipeId)) if (!string.IsNullOrEmpty(_wipeId))
frame["wipeId"] = _wipeId; frame["wipeId"] = _wipeId;
@@ -1035,6 +1124,17 @@ namespace Oxide.Plugins
{ {
MarkHook("OnServerInitialized"); MarkHook("OnServerInitialized");
// Re-read the wipe, and this is not belt-and-braces: at a real server
// start `Init` runs BEFORE the save is loaded, so `SaveCreatedTime` is not yet
// meaningful and the id resolved there is null for the entire session. This hook is
// the first moment the world is actually there.
//
// It was invisible on a development rig for the reason such things usually are: a
// hot-reloaded plugin reads an already-loaded world and gets the right answer every
// time. It took a server that BOOTED with the plugin installed — which is every real
// one — to show `wipeId=none` on a server with a save sitting on disk.
_wipeId = ResolveWipeId();
// Started here rather than in Init: this hook fires both at the end of server startup // Started here rather than in Init: this hook fires both at the end of server startup
// and immediately on a hotload, so it is the one place that runs exactly once per // and immediately on a hotload, so it is the one place that runs exactly once per
// load with the world available either way. // load with the world available either way.