Files
Claude 13b6fc02a4 feat(asset-bridge): the shard's own files stop needing a shared filesystem (Phase 7)
The spawn atlas was the one place the platform's rule -- only the sidecar
bridges the shard -- was broken, and it was broken by the component that faces
the internet: SPAWN_ATLAS.md required the website to read the ServUO tree off a
bind mount or a shared volume. This serves those files over the loopback link
instead (docs/link/v8.md 10).

The measurement came first and changed the shape. 10 said the shard would serve
`tree/<label>` -> bytes; against a stock 57.4 tree it cannot. Spawns/trammel.xml
is 4.03 MB, the sidecar discards any inbound line over 1 MiB, and that file as
one base64 row is 5.4 MiB -- it would be dropped, time out, and be re-requested
forever with no error anywhere. Two files on a STOCK tree are in that state.

So a file crosses as 512 KiB chunks, each gzipped: tree/Spawns/trammel.xml/c0
and so on, which is 5's depth scheme doing the same job it does for
body/400/a0/f0 and needing no protocol change to do it. The chunk is the bound
and the compression is only the saving -- nothing guarantees an operator's files
compress, so the ceiling has to hold when they do not, and a 512 KiB chunk that
refuses to compress is still ~683 KiB of base64, inside the wire cap that
AssetBatchBytes' deliberate factor of two leaves room for.

It is a `tree` FAMILY on assets.fetch rather than 14's separate tree.* commands:
phase 5 had already learned that the command is the transport and the family is
a property of the key, and assets.manifest is generalised here the same way.
That reuses the single slot, the paging envelope, the key ceiling and the
mid-import guard -- and leaves `link` with nothing to do for the third phase
running.

But it gets its OWN consent, Bridge.TreeEnabled. AssetsEnabled is an operator
agreeing the website may read their EA-licensed UO client; this is the shard's
own configuration, which they wrote, and which the public bestiary is built
from. One switch could not express both, and the thing that would silently
disappear for an operator who declined the first is their spawn atlas. So the
consent check moved into the family lookup, and assets.sources answers whenever
either plane is on, reporting `families` filtered to what is actually enabled --
which is how a tree-only shard's website discovers there is anything to ask for.

Two defects found, and which harness found which is the part worth keeping:

  - An empty `catalog` is not an absent one. `expected != null` refused every
    fetch from a caller that sent "", with a sentence naming no catalog at all.
    Found by an offline probe that passed one by accident.
  - GZipStream writes NOTHING for zero bytes of input -- the header is emitted
    lazily, so a stream opened and closed without a write yields a zero-length
    buffer rather than the 20-byte empty member. Stock ServUO ships two empty
    decoration files, so this broke every import off an untouched tree. The
    offline probe reassembled all 141 files and reported success, because .NET's
    own decompressor reads an empty stream as empty data and the chunk's
    declared length (0) and hash (of nothing) both agreed. Only the live walk,
    through a reader on another runtime, disagreed.

Measured end to end against a live shard, the real sidecar and the website's own
reader: 141 files, 11,895,427 bytes, 158 chunks, 3 pages, 1.33 MB on the wire,
512 ms; every file byte-identical to disk; the atlas built over the bridge
identical to the one built off it. A drift check is the manifest alone -- 32 KB,
~70 ms, no file bytes.

The label set is this shard's, never the caller's: a fetch resolves against the
set the shard itself enumerated, and tree/../../Scripts/..., Config/Bridge.cfg
and Saves/Accounts/accounts.xml are all answered `absent` before a path is built
out of them.

Protocol stays 8 and EXTRACTOR_VERSION stays 3 -- this family derives nothing,
it forwards an operator's own file unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-14 02:00:22 -05:00

272 lines
11 KiB
C#

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;
}
// Protocol 6. A command may carry an `idempotencyKey`, and one that does is executed at
// most once: a repeat is answered with the original reply rather than re-run. The gate
// is here rather than in each handler so it covers every inbound kind — including the
// ones a later protocol adds, which is the half that is easy to forget. A command with
// no key behaves exactly as it did before, which is what keeps the admin screens (which
// send none) unchanged.
var idempotencyKey = BridgeJson.GetString(obj, "idempotencyKey");
if (idempotencyKey == null)
{
handler(obj);
return;
}
if (BridgeIdempotency.Intercept(idempotencyKey, obj))
return; // already answered: a replay of the original reply, or bridge.busy
string error = null;
try
{
handler(obj);
}
catch (Exception ex)
{
// Swallowed deliberately, and only on the keyed path: the key must be closed out
// with a definite answer (see BridgeIdempotency's header) rather than left in
// flight by an exception unwinding past Finish. Unkeyed commands still throw the
// way they always have.
error = ex.Message;
Console.WriteLine("[Bridge] handler for '{0}' threw: {1}", kind, ex);
}
finally
{
BridgeIdempotency.Finish(idempotencyKey, error);
}
}
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 | sweepnow]")]
[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();
BridgeSweeps.Rearm();
BridgePages.Rearm();
BridgeChamps.Rearm();
BridgeSocial.Rearm();
BridgeGovernance.Rearm();
BridgePresence.Rearm();
BridgeHousing.Rearm();
BridgePoints.Rearm();
BridgeMarket.Rearm();
BridgeParticipation.Rearm();
BridgeLeases.Rearm();
BridgeWorld.Rearm();
// Not a sweep, so it has nothing to re-arm — but an operator who just edited a
// .cfg wants the change on the site now, not after a shard restart.
BridgeRuleset.Emit();
e.Mobile.SendMessage("Bridge: {0}", BridgeConfig.Describe());
e.Mobile.SendMessage("Bridge: sweeps re-armed; ruleset re-emitted; endpoint changes take effect on reconnect.");
break;
case "ping":
BridgeLink.Emit(BridgeJson.Begin("ping").End());
e.Mobile.SendMessage("Bridge: ping queued.");
break;
case "sweepnow":
BridgeSweeps.SweepOnce();
BridgeChamps.SweepOnce();
BridgeSocial.SweepOnce();
BridgeGovernance.SweepOnce();
BridgePresence.SweepOnce();
BridgeHousing.SweepOnce();
BridgePoints.SweepOnce();
BridgeMarket.SweepOnce();
BridgeParticipation.SweepOnce();
e.Mobile.SendMessage("Bridge: ran one sweep of each stream.");
e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeChamps.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeSocial.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeGovernance.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgePresence.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeHousing.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgePoints.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeMarket.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeParticipation.Status());
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);
e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeChamps.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeSocial.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeGovernance.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgePresence.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeHousing.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgePoints.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeMarket.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgePages.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeRuleset.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeIdempotency.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeLeases.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeParticipation.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeWorld.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeOneShots.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeAssets.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeCatalog.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeArt.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeTree.Status());
break;
}
}
}
}