Files
wtclaude 7aa7bc8032 feat(bridge): protocol 6 — an idempotency key, and champ.boss.killed (Phase 11a)
A command carrying an `idempotencyKey` is now executed at most once: a repeat is
answered with the original reply rather than re-run. That is the precondition
every world verb in Phase 12 is waiting on, and it is what let `uo.broadcast`
stop being un-retryable.

The gate sits in BridgeBoot's inbound dispatch, not in each handler, so it covers
every kind including ones a later protocol adds. A command with no key behaves
exactly as it did before, which leaves the admin screens unchanged.

Four rules, each a decision rather than an implementation detail: reserve on
receipt (so a handler that defers is covered, answering `bridge.busy` to a repeat
in flight); a key that has begun is never released, not even when the handler
throws; a replay is stamped with the REPEAT's correlation id, because the
sidecar's reqId is fresh per call and replaying the original would hang the retry;
and the bound is loud, because an evicted key is the guarantee's one hole.

`champ.boss.killed` rides along because a bump costs a release, a bundle and an
operator update on every shard. It fires from EventSink.CreatureDeath, detected
by type so a boss that popped and died inside one sweep is still reported, and it
carries the damage table that exists at the death and nowhere else.

overlay.toml protocol = 6, in this commit rather than a later one.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-04 14:56:49 -05:00

342 lines
12 KiB
C#

using System;
using System.Collections.Concurrent;
using System.IO;
using System.Net.Sockets;
using System.Text;
using System.Threading;
namespace Server.Custom.Bridge
{
/// <summary>
/// The loopback link to the Rust sidecar. Newline-delimited JSON, bidirectional.
///
/// Threading contract, which the whole bridge depends on:
///
/// * <see cref="Emit"/> is called from the Core thread. It formats nothing, blocks on
/// nothing, and touches no socket. It enqueues and returns. A slow, wedged, or absent
/// sidecar cannot stall the shard.
/// * One link thread owns the socket. It connects, drains the queue, and reconnects with
/// backoff. A single writer keeps event ordering intact.
/// * A reader thread parses inbound lines and hands each to the Core thread via
/// Timer.DelayCall. The reader never touches World, Mobile, Item, or Account.
///
/// The outbound queue is bounded. On overflow the oldest record is dropped and counted,
/// because telemetry is worth less than the shard's memory.
/// </summary>
public static class BridgeLink
{
private static readonly ConcurrentQueue<string> _outbound = new ConcurrentQueue<string>();
private static readonly AutoResetEvent _wake = new AutoResetEvent(false);
private static Thread _link;
private static volatile bool _running;
private static volatile bool _connected;
// Set when the peer goes away, so the writer stops trying.
private static volatile bool _dead;
// Incremented per connection attempt. A reader from a previous connection must not be
// able to mark a newer one dead — reader.Join can time out, and the stale thread's
// finally block would otherwise tear down the connection that replaced it.
private static int _epoch;
/// <summary>
/// Loopback reconnects are cheap, so the ceiling is low. A sidecar restart should cost
/// a few seconds of buffering, not half a minute.
/// </summary>
private const int MaxBackoffMs = 5000;
private static int _depth;
private static long _sent, _dropped, _received, _connects, _writeErrors;
/// <summary>Raised on the <b>Core thread</b>, one call per inbound line.</summary>
public static event Action<string> InboundLine;
/// <summary>
/// Raised on the <b>Core thread</b> after each successful connect. The sidecar may
/// restart independently of the shard, so anything it needs to know up front has to be
/// re-sent per connection, not once at ServerStarted.
/// </summary>
public static event Action Connected_Core;
public static bool Connected { get { return _connected; } }
public static int Depth { get { return Volatile.Read(ref _depth); } }
public static long Sent { get { return Interlocked.Read(ref _sent); } }
public static long Dropped { get { return Interlocked.Read(ref _dropped); } }
public static long Received { get { return Interlocked.Read(ref _received); } }
/// <summary>Total successful connections, so the first connect counts as 1.</summary>
public static long Connects { get { return Interlocked.Read(ref _connects); } }
public static long WriteErrors { get { return Interlocked.Read(ref _writeErrors); } }
public static void Start()
{
if (_running)
return;
_running = true;
_link = new Thread(LinkLoop)
{
Name = "Bridge Link",
IsBackground = true
};
_link.Start();
}
public static void Stop()
{
if (!_running)
return;
_running = false;
_wake.Set();
var t = _link;
if (t != null && !t.Join(TimeSpan.FromSeconds(2.0)))
Console.WriteLine("[Bridge] link thread did not stop cleanly");
_link = null;
_connected = false;
}
/// <summary>
/// Core thread. Non-blocking. `line` must already be a complete JSON object with no
/// embedded newline; the newline is appended by the writer as the frame delimiter.
/// </summary>
public static void Emit(string line)
{
if (line == null)
return;
// Protocol 6. While a keyed command's handler runs — Core thread, one at a time — every
// line it emits is offered to the recent-key store so the correlated reply can be
// replayed to a retry later. Deliberately BEFORE the `_running` check: a reply the link
// was too dead to deliver is precisely the one a retry will come back for.
if (BridgeIdempotency.Capturing)
BridgeIdempotency.Observe(line);
if (!_running)
return;
// Drop-oldest. Bound first, then enqueue, so the queue can transiently sit one over
// the cap but never grows without limit.
while (Volatile.Read(ref _depth) >= BridgeConfig.QueueCap)
{
string discard;
if (!_outbound.TryDequeue(out discard))
break;
Interlocked.Decrement(ref _depth);
Interlocked.Increment(ref _dropped);
}
_outbound.Enqueue(line);
Interlocked.Increment(ref _depth);
_wake.Set();
}
private static void LinkLoop()
{
int backoffMs = 500;
while (_running)
{
TcpClient client = null;
Thread reader = null;
int epoch = Interlocked.Increment(ref _epoch);
try
{
client = new TcpClient();
client.NoDelay = true;
client.Connect(BridgeConfig.Host, BridgeConfig.Port);
var stream = client.GetStream();
stream.WriteTimeout = 5000; // a wedged peer must surface as an error, not a hang
_dead = false;
_connected = true;
backoffMs = 500;
Interlocked.Increment(ref _connects);
Console.WriteLine("[Bridge] connected to {0}:{1}", BridgeConfig.Host, BridgeConfig.Port);
// Building the greeting reads the world, so it must happen on the Core thread.
Timer.DelayCall(TimeSpan.Zero, () =>
{
try
{
var handler = Connected_Core;
if (handler != null)
handler();
}
catch (Exception ex)
{
Console.WriteLine("[Bridge] connect handler threw: {0}", ex);
}
});
var localStream = stream;
reader = new Thread(() => ReadLoop(localStream, epoch))
{
Name = "Bridge Reader",
IsBackground = true
};
reader.Start();
WriteLoop(stream);
}
catch (Exception ex)
{
if (_connected)
Console.WriteLine("[Bridge] link error: {0}", ex.Message);
}
finally
{
_connected = false;
_dead = true;
try { if (client != null) client.Close(); }
catch { }
if (reader != null)
reader.Join(TimeSpan.FromSeconds(1.0));
}
if (!_running)
break;
// Nothing is listening yet, or the sidecar restarted. Both are normal.
Thread.Sleep(backoffMs);
backoffMs = Math.Min(backoffMs * 2, MaxBackoffMs);
}
_connected = false;
}
private static void WriteLoop(NetworkStream stream)
{
while (_running && !_dead)
{
string line;
if (!_outbound.TryDequeue(out line))
{
_wake.WaitOne(250);
continue;
}
Interlocked.Decrement(ref _depth);
try
{
var bytes = Encoding.UTF8.GetBytes(line + "\n");
stream.Write(bytes, 0, bytes.Length);
Interlocked.Increment(ref _sent);
}
catch (Exception)
{
// The record is already off the queue. Count it and let the outer loop
// reconnect; re-queueing risks an unbounded retry storm against a dead peer.
Interlocked.Increment(ref _writeErrors);
_dead = true;
throw;
}
}
}
private static void ReadLoop(NetworkStream stream, int epoch)
{
var buffer = new byte[8192];
var line = new StringBuilder(512);
try
{
while (_running && !_dead)
{
int read = stream.Read(buffer, 0, buffer.Length);
if (read <= 0)
break; // clean EOF: the sidecar closed. Normal.
for (int i = 0; i < read; i++)
{
char c = (char)buffer[i];
if (c == '\n')
{
Dispatch(line.ToString());
line.Clear();
}
else if (c != '\r')
{
line.Append(c);
if (line.Length > (1 << 20))
{
Console.WriteLine("[Bridge] inbound line too long, dropping");
line.Clear();
}
}
}
}
}
catch (IOException)
{
// Expected when the peer vanishes mid-read.
}
catch (ObjectDisposedException)
{
// Expected when Stop() closes the socket under us.
}
catch (Exception ex)
{
Console.WriteLine("[Bridge] reader error: {0}", ex.Message);
}
finally
{
// Only tear down the connection this reader actually owned.
if (Volatile.Read(ref _epoch) == epoch)
{
_dead = true;
_wake.Set(); // let the writer notice and fall through to reconnect
}
}
}
/// <summary>
/// Reader thread. Marshals to the Core thread. Timer.DelayCall's scheduling path is
/// lock-protected and safe to call from any thread; the callback runs on the main loop.
/// </summary>
private static void Dispatch(string line)
{
if (line.Length == 0)
return;
Interlocked.Increment(ref _received);
Timer.DelayCall(TimeSpan.Zero, () =>
{
try
{
var handler = InboundLine;
if (handler != null)
handler(line);
}
catch (Exception ex)
{
// A malformed command must never escape into a game code path.
Console.WriteLine("[Bridge] inbound handler threw: {0}", ex);
}
});
}
}
}