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:
2026-07-10 05:03:03 -05:00
parent 0ef756a93a
commit 9c02ba45dc
8 changed files with 822 additions and 5 deletions

View File

@@ -0,0 +1,169 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using System.Web.Script.Serialization;
namespace Server.Custom.Bridge
{
/// <summary>
/// Outbound JSON is written by hand into a StringBuilder. It runs on the Core thread for
/// every emitted event, and the measured budget in docs/PLAN.md assumes this cost, not a
/// reflection serializer's.
///
/// Inbound JSON is parsed with JavaScriptSerializer. Commands arrive at human rates, so
/// correctness beats speed there, and parsing happens on the reader thread anyway.
/// </summary>
public static class BridgeJson
{
private static readonly DateTime Epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
[ThreadStatic]
private static JavaScriptSerializer _parser;
public static long NowMs()
{
return (long)(DateTime.UtcNow - Epoch).TotalMilliseconds;
}
// ---- outbound ----
/// <summary>Opens an object and writes the `t` and `kind` fields.</summary>
public static StringBuilder Begin(string kind)
{
var sb = new StringBuilder(256);
sb.Append("{\"t\":").Append(NowMs());
sb.Append(",\"kind\":\"").Append(kind).Append('"');
return sb;
}
public static StringBuilder Str(this StringBuilder sb, string name, string value)
{
sb.Append(",\"").Append(name).Append("\":");
if (value == null)
sb.Append("null");
else
Escape(sb, value);
return sb;
}
public static StringBuilder Num(this StringBuilder sb, string name, long value)
{
sb.Append(",\"").Append(name).Append("\":").Append(value);
return sb;
}
public static StringBuilder Num(this StringBuilder sb, string name, double value)
{
sb.Append(",\"").Append(name).Append("\":")
.Append(value.ToString("R", CultureInfo.InvariantCulture));
return sb;
}
public static StringBuilder Bool(this StringBuilder sb, string name, bool value)
{
sb.Append(",\"").Append(name).Append("\":").Append(value ? "true" : "false");
return sb;
}
/// <summary>Serial as the canonical "0x1A2B" string the sidecar keys on.</summary>
public static StringBuilder Ser(this StringBuilder sb, string name, Serial serial)
{
sb.Append(",\"").Append(name).Append("\":\"0x")
.Append(serial.Value.ToString("X")).Append('"');
return sb;
}
/// <summary>Closes the object. The trailing newline is the frame delimiter.</summary>
public static string End(this StringBuilder sb)
{
sb.Append('}');
return sb.ToString();
}
public static void Escape(StringBuilder sb, string value)
{
sb.Append('"');
for (int i = 0; i < value.Length; i++)
{
char c = value[i];
switch (c)
{
case '"': sb.Append("\\\""); break;
case '\\': sb.Append("\\\\"); break;
case '\n': sb.Append("\\n"); break;
case '\r': sb.Append("\\r"); break;
case '\t': sb.Append("\\t"); break;
case '\b': sb.Append("\\b"); break;
case '\f': sb.Append("\\f"); break;
default:
if (c < ' ')
sb.Append("\\u").Append(((int)c).ToString("x4"));
else
sb.Append(c);
break;
}
}
sb.Append('"');
}
// ---- inbound ----
/// <summary>
/// Parses one line into a dictionary. Returns null on malformed input rather than
/// throwing: a bad line from the sidecar must never reach a game code path.
/// </summary>
public static Dictionary<string, object> Parse(string line)
{
if (String.IsNullOrEmpty(line))
return null;
try
{
if (_parser == null)
{
_parser = new JavaScriptSerializer();
_parser.MaxJsonLength = 1 << 20;
}
return _parser.Deserialize<Dictionary<string, object>>(line);
}
catch
{
return null;
}
}
public static string GetString(Dictionary<string, object> o, string key)
{
object v;
if (o == null || !o.TryGetValue(key, out v) || v == null)
return null;
return v as string ?? Convert.ToString(v, CultureInfo.InvariantCulture);
}
public static int GetInt(Dictionary<string, object> o, string key, int fallback)
{
object v;
if (o == null || !o.TryGetValue(key, out v) || v == null)
return fallback;
try
{
return Convert.ToInt32(v, CultureInfo.InvariantCulture);
}
catch
{
return fallback;
}
}
}
}