Phase 1: loopback transport to the sidecar
BridgeLink owns a TcpClient to 127.0.0.1 and nothing else touches it. Emit() is
called from the Core thread; it enqueues onto a bounded drop-oldest queue and
returns. A link thread drains the queue and reconnects with backoff; a reader
thread parses inbound lines and marshals each to the Core thread via
Timer.DelayCall. An absent, slow, or wedged sidecar therefore cannot stall the
shard, which is the property the rest of the bridge depends on.
Outbound JSON is written by hand into a StringBuilder because it runs on the
Core thread for every event and the measured budget assumes that cost. Inbound
uses JavaScriptSerializer: commands arrive at human rates, so correctness beats
speed, and parsing happens off the Core thread anyway. That needs a
System.Web.Extensions reference.
server.hello is emitted per connection rather than once at ServerStarted. A
sidecar that restarts independently would otherwise never learn which shard it
is attached to. It carries a bootId, stable across reconnects and fresh on every
shard restart, so the sidecar can tell "I reconnected" from "the shard
restarted" and keep or discard its cache accordingly.
Two defects found by testing and fixed before commit:
- Backoff ceiling was 30s, so a sidecar restart cost up to half a minute of
buffering on a loopback socket. Now 5s.
- A stale reader could kill a fresh connection: reader.Join(1s) can time out,
and the old thread's finally block then set the shared _dead flag, possibly
tearing down the connection that had replaced it. Connections now carry an
epoch and a reader only marks dead the one it owned.
Acceptance evidence recorded in docs/PLAN.md §11: boots with no sidecar, buffers
through the outage and drains on connect, round-trips ping/pong on the Core
thread, survives unknown kinds and malformed JSON, and reconnects unattended.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
180
overlay/Scripts/Custom/Bridge/BridgeBoot.cs
Normal file
180
overlay/Scripts/Custom/Bridge/BridgeBoot.cs
Normal file
@@ -0,0 +1,180 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
using Server.Commands;
|
||||
|
||||
namespace Server.Custom.Bridge
|
||||
{
|
||||
/// <summary>
|
||||
/// Lifecycle wiring. Boot order (Server/Main.cs:544-562, all on the Core thread):
|
||||
///
|
||||
/// Configure() -> World.Load() -> Initialize() -> EventSink.ServerStarted
|
||||
///
|
||||
/// Config is read in Configure. Handlers are attached in Initialize. The socket opens on
|
||||
/// ServerStarted, once the world is actually there to describe.
|
||||
///
|
||||
/// EventSink.Shutdown does NOT fire on a crash (Server/Main.cs:198,313), so the sidecar
|
||||
/// must treat socket EOF as normal and re-handshake rather than waiting for a goodbye.
|
||||
/// </summary>
|
||||
public static class BridgeBoot
|
||||
{
|
||||
private static readonly Dictionary<string, Action<Dictionary<string, object>>> _handlers =
|
||||
new Dictionary<string, Action<Dictionary<string, object>>>(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Identifies this run of the shard. It is stable across sidecar reconnects and changes
|
||||
/// on every shard restart, which is how the sidecar tells "I reconnected" (keep my
|
||||
/// cached state) from "the shard restarted" (discard it).
|
||||
/// </summary>
|
||||
private static string _bootId;
|
||||
|
||||
public static void Configure()
|
||||
{
|
||||
BridgeConfig.Configure();
|
||||
}
|
||||
|
||||
public static void Initialize()
|
||||
{
|
||||
if (!BridgeConfig.Enabled)
|
||||
{
|
||||
Console.WriteLine("[Bridge] disabled by config");
|
||||
return;
|
||||
}
|
||||
|
||||
CommandSystem.Register("bridge", AccessLevel.Administrator, Bridge_OnCommand);
|
||||
|
||||
RegisterHandler("ping", OnPing);
|
||||
|
||||
BridgeLink.InboundLine += OnInboundLine;
|
||||
BridgeLink.Connected_Core += EmitHello;
|
||||
|
||||
EventSink.ServerStarted += OnServerStarted;
|
||||
EventSink.Shutdown += OnShutdown;
|
||||
EventSink.Crashed += OnCrashed;
|
||||
|
||||
Console.WriteLine("[Bridge] {0}", BridgeConfig.Describe());
|
||||
}
|
||||
|
||||
/// <summary>Handlers run on the Core thread. They may touch the world freely.</summary>
|
||||
public static void RegisterHandler(string kind, Action<Dictionary<string, object>> handler)
|
||||
{
|
||||
_handlers[kind] = handler;
|
||||
}
|
||||
|
||||
private static void OnServerStarted()
|
||||
{
|
||||
_bootId = Guid.NewGuid().ToString("N");
|
||||
BridgeLink.Start();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Core thread, once per connection. The sidecar restarts independently of the shard,
|
||||
/// so this is sent on every connect rather than once at boot — otherwise a sidecar that
|
||||
/// came up second would never learn which shard it is talking to.
|
||||
/// </summary>
|
||||
private static void EmitHello()
|
||||
{
|
||||
BridgeLink.Emit(BridgeJson.Begin("server.hello")
|
||||
.Str("shard", Server.Misc.ServerList.ServerName)
|
||||
.Str("bootId", _bootId)
|
||||
.Num("connects", BridgeLink.Connects)
|
||||
.Num("items", World.Items.Count)
|
||||
.Num("mobiles", World.Mobiles.Count)
|
||||
.Num("accounts", Accounting.Accounts.Count)
|
||||
.End());
|
||||
}
|
||||
|
||||
private static void OnShutdown(ShutdownEventArgs e)
|
||||
{
|
||||
BridgeLink.Emit(BridgeJson.Begin("server.shutdown").End());
|
||||
|
||||
// Stop() joins the link thread for up to 2s, which gives the writer a chance to drain
|
||||
// the goodbye. Best effort: the sidecar must not depend on receiving it.
|
||||
BridgeLink.Stop();
|
||||
}
|
||||
|
||||
private static void OnCrashed(CrashedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
BridgeLink.Emit(BridgeJson.Begin("server.crashed")
|
||||
.Str("error", e.Exception == null ? null : e.Exception.Message)
|
||||
.End());
|
||||
|
||||
BridgeLink.Stop();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// The process is already going down. Never make a crash worse.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Core thread, one call per inbound line.</summary>
|
||||
private static void OnInboundLine(string line)
|
||||
{
|
||||
var obj = BridgeJson.Parse(line);
|
||||
|
||||
if (obj == null)
|
||||
{
|
||||
Console.WriteLine("[Bridge] malformed inbound line, ignoring");
|
||||
return;
|
||||
}
|
||||
|
||||
var kind = BridgeJson.GetString(obj, "kind");
|
||||
|
||||
if (kind == null)
|
||||
return;
|
||||
|
||||
Action<Dictionary<string, object>> handler;
|
||||
|
||||
if (!_handlers.TryGetValue(kind, out handler))
|
||||
{
|
||||
Console.WriteLine("[Bridge] no handler for inbound kind '{0}'", kind);
|
||||
return;
|
||||
}
|
||||
|
||||
handler(obj);
|
||||
}
|
||||
|
||||
private static void OnPing(Dictionary<string, object> o)
|
||||
{
|
||||
var sb = BridgeJson.Begin("pong");
|
||||
|
||||
var id = BridgeJson.GetString(o, "id");
|
||||
|
||||
if (id != null)
|
||||
sb.Str("id", id);
|
||||
|
||||
BridgeLink.Emit(sb.End());
|
||||
}
|
||||
|
||||
[Usage("bridge [status | reload | ping]")]
|
||||
[Description("Inspects and controls the sidecar link.")]
|
||||
private static void Bridge_OnCommand(CommandEventArgs e)
|
||||
{
|
||||
var arg = e.Length > 0 ? e.GetString(0).ToLowerInvariant() : "status";
|
||||
|
||||
switch (arg)
|
||||
{
|
||||
case "reload":
|
||||
BridgeConfig.Load();
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgeConfig.Describe());
|
||||
e.Mobile.SendMessage("Bridge: endpoint changes take effect on reconnect.");
|
||||
break;
|
||||
|
||||
case "ping":
|
||||
BridgeLink.Emit(BridgeJson.Begin("ping").End());
|
||||
e.Mobile.SendMessage("Bridge: ping queued.");
|
||||
break;
|
||||
|
||||
default:
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgeConfig.Describe());
|
||||
e.Mobile.SendMessage(
|
||||
"Bridge: connected={0} depth={1} sent={2} dropped={3} received={4} connects={5} writeErrors={6}",
|
||||
BridgeLink.Connected, BridgeLink.Depth, BridgeLink.Sent, BridgeLink.Dropped,
|
||||
BridgeLink.Received, BridgeLink.Connects, BridgeLink.WriteErrors);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user