From 35c85d542a945015b457d9b0f87c44698565e351 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 05:03:03 -0500 Subject: [PATCH] Phase 1: loopback transport to the sidecar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- link/PLAN.md | 37 +++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/link/PLAN.md b/link/PLAN.md index dc3ecb9..37edf26 100644 --- a/link/PLAN.md +++ b/link/PLAN.md @@ -236,6 +236,10 @@ Newline-delimited JSON, one object per line, `serial` as the primary key. ### Outbound (shard → sidecar) ```jsonc +{"t":1752…,"kind":"server.hello","shard":"My Shard","bootId":"8a9f34c5…","connects":2, + "items":206467,"mobiles":42826,"accounts":51} +{"t":1752…,"kind":"server.shutdown"} +{"t":1752…,"kind":"server.crashed","error":"…"} {"t":1752…,"kind":"mob.login","serial":"0x1A2B","name":"Thunderheat","acct":"PerryAdimn","webId":"9931"} {"t":1752…,"kind":"char.vitals","serial":"0x1A2B","hits":95,"hitsMax":100,"mana":40,"stam":88, "str":100,"dex":90,"int":45,"x":1420,"y":1631,"online":true} @@ -268,6 +272,14 @@ Newline-delimited JSON, one object per line, `serial` as the primary key. Every inbound handler marshals to the Core thread before touching world state. +### `server.hello` is per-connection, not per-boot + +The sidecar restarts independently of the shard, so anything it needs up front must be re-sent on **every** connect. An earlier draft emitted `server.started` once at `EventSink.ServerStarted`; a sidecar that came up second never received it and had no idea which shard it was attached to. + +`bootId` is a GUID generated at `ServerStarted`. It is stable across sidecar reconnects and changes on every shard restart, which is how the sidecar distinguishes *"I reconnected"* (keep cached state) from *"the shard restarted"* (discard it). `connects` is the shard's count of successful connections, so the first `hello` of a run carries `connects:1`. + +Counts in `hello` are a live snapshot taken on the Core thread, not a cached value — two hellos from the same boot will disagree, because the world keeps spawning. + ### Item names are clilocs `Item.Name` is frequently `null`; the display name is `LabelNumber`, a cliloc id. **There is no `Data/Cliloc.enu` in this repo** — `BRIDGE_FINDINGS.md` §IV.4 is wrong about this. Cliloc data lives in the client install, which `DataPath` resolves to `D:\Games\Electronic Arts\Ultima Online Classic\`. Ship **both** `name` (when non-null) and `cliloc`, and resolve the number **on the website** against a cliloc map. That avoids a server-side dependency on the client directory. @@ -289,8 +301,8 @@ Every inbound handler marshals to the Core thread before touching world state. ## 9. Implementation phases -0. **Fix the build** (§3). Nothing below loads until this is done. -1. **Transport.** `BridgeLink`: `TcpClient`, writer thread + bounded queue, reader thread → `Timer.DelayCall`. Emit `ServerStarted` / `Shutdown` / `Crashed` only. Prove the sidecar can restart independently while the shard runs. +0. ~~**Fix the build** (§3).~~ **Done.** Verified: a plain boot now logs `Core: Compiling scripts... / Build succeeded.` +1. ~~**Transport.**~~ **Done.** `BridgeLink`: `TcpClient`, link thread + bounded drop-oldest queue, reader thread → `Timer.DelayCall`, reconnect with backoff capped at 5 s. Emits `server.hello` / `server.shutdown` / `server.crashed`, answers `ping` with `pong`. `[bridge status|reload|ping]`. Acceptance evidence in §11. 2. **Cheap event streams.** `Login`, `Logout`, `AccountGoldChange`, `ValidVendorPurchase`, `ValidVendorSell`, `PlayerDeath`, `PlayerMurdered`, `SkillGain`, `QuestComplete`. 3. **Sweeps.** Vitals (30 s), decay-on-transition (60 s, with silent `ServerStarted` baseline), economy supply (5 min). All config-tunable; `[bridge reload` re-arms the timers. 4. **Request/response.** `char.profile`, `account.roster`, `vendor.snapshot`. Sidecar caches profiles; rate-limit requests sidecar-side. @@ -314,6 +326,27 @@ Read in `Configure()` via `Config.Get("Bridge.", default)`. Key scope is --- +## 11. Phase 1 acceptance + +Run against the seeded shard with `tools/stub_sidecar.ps1`. Each of these is a claim the rest of the bridge leans on, so each was observed rather than assumed. + +| Claim | Evidence | +|-------|----------| +| The shard boots normally with **no sidecar listening**. | World loaded in 4.53 s, game port up, no stall, no error spam, CPU flat. | +| Events emitted while disconnected are **buffered and delivered on connect**. | `server.hello` carried `t=…070312` (boot) but arrived at `…114209`, 44 s later, when the sidecar first appeared. | +| Inbound commands execute on the **Core thread**. | `{"kind":"ping","id":"t1"}` → `{"kind":"pong","id":"t1"}`. | +| An **unknown kind** is ignored, not fatal. | `[Bridge] no handler for inbound kind 'nonsense.kind'` | +| **Malformed JSON** does not kill the reader. | `[Bridge] malformed inbound line, ignoring`, connection stayed up. | +| Killing the sidecar **does not disturb the shard**. | Shard stayed up, CPU unchanged, no exception, no log spam. | +| The shard **reconnects unattended**. | Second `[Bridge] connected`, `hello` re-sent with `connects:2` and the same `bootId`. | + +Two defects were found this way and fixed: + +- **Backoff ceiling was 30 s**, so a sidecar restart could cost half a minute of buffering on a loopback socket. Now 5 s. +- **A stale reader could kill a fresh connection.** `reader.Join(1s)` can time out, and the old reader's `finally` then set the shared `_dead` flag — potentially tearing down the connection that had already replaced it. Each connection now carries an epoch, and a reader only marks dead the connection it owned. + +--- + ## 10. Operational notes - **Commands and timers do not run during a world save.** `TimerMain` early-continues while `World.Saving || World.Loading` (`Server/Timer.cs:322`), and the main loop is inside `World.Save` anyway. A `link.confirm` arriving mid-save is delayed seconds. The website should show "confirming…", not fail.