fix(bridge): resolve the wipe at OnServerInitialized, and bound the connect
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 14s
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 14s
Two defects the Carbon rig found, neither visible on a development loopback. **The wipe id was null for every real session.** `Init` runs before the save is loaded, so `SaveRestore.SaveCreatedTime` is not yet meaningful there and the id resolved at load time stayed null for the life of the process — every frame shipped without the field R12 splits history on. It was invisible because 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` beside a save sitting on disk. Resolved again at `OnServerInitialized`, and lazily while still unknown. **Unload blocked the main thread for two seconds against an unreachable sidecar.** Observed as `hook 'Unload' took longer than 100ms [2002ms]` next to `link thread did not stop cleanly` — phase 1's stall arriving by a different road. The link thread sits in a blocking `TcpClient.Connect`, which has no timeout of its own and cannot be woken; a host that REFUSES answers instantly, which is every loopback test, and a host that DROPS does not answer at all. The connect is now bounded, and waits on a stop handle of its own so a reload abandons it at once. It cannot share `Wake`, which also means 'the queue has something in it' and is signalled by every hook. After: the same reload logs no slow-hook warning and no stranded thread. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
@@ -67,6 +67,25 @@ namespace Oxide.Plugins
|
||||
/// <summary>The longest line accepted from the sidecar. Over-long lines are discarded.</summary>
|
||||
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>
|
||||
/// 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 —
|
||||
@@ -84,6 +103,18 @@ namespace Oxide.Plugins
|
||||
private static readonly ConcurrentQueue<string> Outbound = new ConcurrentQueue<string>();
|
||||
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 Thread _link;
|
||||
@@ -223,10 +254,19 @@ namespace Oxide.Plugins
|
||||
|
||||
private void Unload()
|
||||
{
|
||||
// Best-effort, and it has to happen BEFORE `_running` goes false, because `Emit`
|
||||
// refuses to enqueue once it does. A tally lost here is one interval of one player's
|
||||
// gathering; flushing costs nothing and the ordering is the only reason it works at
|
||||
// all.
|
||||
// Attempted before `_running` goes false, because `Emit` refuses to enqueue once it
|
||||
// does — but be honest about what this achieves: the writer stops on the same flag a
|
||||
// few lines below and the queue is cleared after the join, so a frame enqueued here
|
||||
// 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
|
||||
{
|
||||
FlushTallies();
|
||||
@@ -243,6 +283,7 @@ namespace Oxide.Plugins
|
||||
}
|
||||
|
||||
_running = false;
|
||||
_stopping.Set();
|
||||
Wake.Set();
|
||||
|
||||
Thread t = _link;
|
||||
@@ -385,7 +426,7 @@ namespace Oxide.Plugins
|
||||
|
||||
client = new TcpClient();
|
||||
client.NoDelay = true;
|
||||
client.Connect(host, port);
|
||||
Connect(client, host, port);
|
||||
|
||||
NetworkStream stream = client.GetStream();
|
||||
stream.WriteTimeout = 5000; // a wedged peer must surface as an error, not a hang
|
||||
@@ -479,6 +520,48 @@ namespace Oxide.Plugins
|
||||
_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)
|
||||
{
|
||||
while (_running && !_dead)
|
||||
@@ -696,6 +779,12 @@ namespace Oxide.Plugins
|
||||
{ "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))
|
||||
frame["wipeId"] = _wipeId;
|
||||
|
||||
@@ -1035,6 +1124,17 @@ namespace Oxide.Plugins
|
||||
{
|
||||
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
|
||||
// and immediately on a hotload, so it is the one place that runs exactly once per
|
||||
// load with the world available either way.
|
||||
|
||||
Reference in New Issue
Block a user