From fc7017e703645bdcc8e02e438c4a9186d2efb37e Mon Sep 17 00:00:00 2001 From: colby Date: Fri, 10 Jul 2026 11:34:14 -0500 Subject: [PATCH] Phase 4: character-profile request/response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BridgeProfile builds the read-models the website consumes; BridgeRequests registers the inbound handlers. The sidecar asks, the shard answers on the Core thread (inbound lines are marshaled through Timer.DelayCall before a handler runs), so all of these read live world state safely. - char.request: resolve by serial, or by account + slot, and reply with a full profile (stats, all trained skills, worn equipment with flattened AOS mods, resists). Works for offline characters since a logged-off mobile stays resident until Delete. - account.roster: light per-character summary, offline chars included. - vendor.snapshot: every player vendor owned by an account, with held gold and priced listings. Each request may carry a reqId the reply echoes so the sidecar can correlate. An unresolvable request gets a bridge.error reply rather than silence, so the website can show a real failure instead of hanging. Verified against the real world with a sending stub: all five requests answered, both char lookup paths (account+slot and serial) returning the identical profile, vendor.snapshot returning seed_000's two vendors and 80 listings, and the bad account returning bridge.error. Two real-data findings noted in docs/PLAN.md §14: a GM character can have skill base > cap (the website must not assume otherwise), and the mod-flattening path still wants a genuinely kitted character to exercise against real suffix gear. Adds tools/stub_sidecar_request.ps1 (sends requests) and a hardened tools/stub_sidecar.ps1 (survives reaping/rebind). Co-Authored-By: Claude Opus 4.8 --- README.md | 10 +- docs/PLAN.md | 23 +- .../Scripts/Custom/Bridge/BridgeProfile.cs | 245 ++++++++++++++++++ .../Scripts/Custom/Bridge/BridgeRequests.cs | 215 +++++++++++++++ tools/stub_sidecar.ps1 | 46 ++-- tools/stub_sidecar_request.ps1 | 65 +++++ 6 files changed, 585 insertions(+), 19 deletions(-) create mode 100644 overlay/Scripts/Custom/Bridge/BridgeProfile.cs create mode 100644 overlay/Scripts/Custom/Bridge/BridgeRequests.cs create mode 100644 tools/stub_sidecar_request.ps1 diff --git a/README.md b/README.md index c36fc6d..92886e7 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ Anything under `overlay/` is authoritative. Do not edit files in the server tree | 1 — transport (`BridgeLink`) | **done, acceptance in `docs/PLAN.md` §11** | | 2 — event streams (`BridgeEvents`) | **done, acceptance in `docs/PLAN.md` §12** | | 3 — sweeps (`BridgeSweeps`) | **done, acceptance in `docs/PLAN.md` §13** | -| 4 — request/response | not started | +| 4 — request/response (`BridgeRequests`) | **done, acceptance in `docs/PLAN.md` §14** | | 5 — `[link` account linking | not started | | 6 — town-crier inbound | not started | | 7 — `PlayerVendorSale` core event | not started | @@ -67,15 +67,21 @@ Runtime script compilation therefore had no effect, silently. `overlay/Scripts/S | `BridgeBoot.cs` | Lifecycle, inbound dispatch, `[bridge status\|reload\|ping]`. | | `BridgeEvents.cs` | EventSink subscriptions (Phase 2). Read-only, player-filtered, never emits secrets. | | `BridgeSweeps.cs` | Polled streams (Phase 3): vitals, house decay on transition, economy supply. Core-thread timers. | +| `BridgeProfile.cs` | Read-model builders (Phase 4): full character profile, account roster. Core-thread reads. | +| `BridgeRequests.cs` | Inbound request handlers (Phase 4): `char.request`, `account.roster`, `vendor.snapshot`, with `bridge.error` replies. | `Emit()` is called from the Core thread. It enqueues and returns — it never touches the socket, never blocks, never allocates a syscall. **A wedged or absent sidecar cannot stall the shard**, and that is the property everything else depends on. ## Testing -`tools/stub_sidecar.ps1` is a loopback listener that logs every line the shard sends. Run it, boot the shard, watch `server.hello` arrive. +`tools/stub_sidecar.ps1` is a loopback listener that logs every line the shard sends. Run it, boot the shard, watch `server.hello` arrive. It survives a just-killed instance (SO_REUSEADDR) and won't die on a transient error. ```powershell .\tools\stub_sidecar.ps1 -Port 7788 -Log .\sidecar.log ``` +`tools/stub_sidecar_request.ps1` additionally *sends* inbound requests (`char.request`, `account.roster`, `vendor.snapshot`, plus an error case) right after the shard connects, and logs the replies — the harness used to validate Phase 4. + +Note: the throwaway PowerShell sidecars are fragile — they get reaped and contend on their log file. The real Rust sidecar replaces them; don't read their flakiness as a shard problem. The shard buffers non-perishable events through any outage and reconnects on its own (observed reconnecting 5× unattended in one session). + `tools/scaffolding/` holds the world seeder and the performance probe. Neither is deployed — `deploy.ps1` only copies `overlay/`. They produced the budget in `docs/PLAN.md` §1. See `tools/scaffolding/README.md`. diff --git a/docs/PLAN.md b/docs/PLAN.md index 76fa16d..5c2dc2d 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -306,7 +306,7 @@ Counts in `hello` are a live snapshot taken on the Core thread, not a cached val 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.**~~ **Done.** `BridgeEvents` subscribes the streams selected below. All observed on the live shard; evidence in §12. 3. ~~**Sweeps.**~~ **Done.** `BridgeSweeps`: vitals / decay-on-transition / economy, all Core-thread timers, re-armable. Evidence in §13. -4. **Request/response.** `char.profile`, `account.roster`, `vendor.snapshot`. Sidecar caches profiles; rate-limit requests sidecar-side. +4. ~~**Request/response.**~~ **Done.** `BridgeProfile` + `BridgeRequests`: `char.profile` (by account+slot or serial), `account.roster`, `vendor.snapshot`, `bridge.error`. Evidence in §14. Sidecar should cache profiles and rate-limit requests. 5. **`[link` account linking.** `CommandSystem.Register("link", AccessLevel.Player, …)`, one-time short-TTL codes in a main-thread dict, `Account.SetTag("WebsiteUserId", id)` — persists to `accounts.xml` for free. Loopback-only is the trust boundary; add a shared secret if the sidecar is ever exposed. 6. **Town-crier inbound.** `GlobalTownCrierEntryList.Instance.AddEntry(lines, duration)` (`Scripts/Mobiles/NPCs/TownCrier.cs:96`), marshaled to the Core thread. Cap line count/length and active entries. 7. **Core edit: `PlayerVendorSale`** (§6). Then the cheat-detection feed. @@ -348,6 +348,27 @@ Two defects were found this way and fixed: --- +## 14. Phase 4 acceptance + +`BridgeProfile.cs` builds the read-models; `BridgeRequests.cs` registers the inbound handlers (`char.request`, `account.roster`, `vendor.snapshot`). Each request may carry a `reqId` the reply echoes; an unresolvable request gets a `bridge.error` reply, never silence. + +Verified against the **real world** with a sending stub (`tools/stub_sidecar_request.ps1`), five requests, all answered on the Core thread: + +- `account.roster` for `whitlocktech` → one char, Darrow, slot 0, offline. +- `char.request` by account+slot → full profile: stats, all 58 skills, resists, worn equipment, `reqId` echoed. +- `char.request` by `serial:"0x24C"` → byte-identical profile. Both resolution paths agree. +- `vendor.snapshot` for `seed_000` → its two vendors, held gold, all 40 priced listings each. +- `char.request` for a bogus account → `{"kind":"bridge.error","reqId":"r-bad","reason":"unknown account"}`. + +Two things the real character surfaced that the seeded dummies could not: + +- **`base > cap` is possible.** Darrow (a GM character) reports every skill `base:120, cap:100`. The website must not assume `base <= cap`. The profile reports both faithfully. +- **The mod-flattening path was not exercised against real suffix gear.** Darrow wears starter shirt/pants/shoes with empty `mods`. The flattening code is the same path proven by the Phase 1 timing probe, but a genuinely kitted character (weapon/armor with AOS attributes) would be the honest end-to-end test. Not blocking. + +Offline profiles work: Darrow was logged out and the full sheet still built, because a logged-off mobile stays resident until Delete. + +--- + ## 13. Phase 3 acceptance `BridgeSweeps.cs` runs three repeating Core-thread timers: vitals (`StatSweepSeconds`), house decay (`DecaySweepSeconds`), economy supply (`EconomySweepSeconds`). All re-armable via `[bridge reload`; `[bridge sweepnow` runs one of each on demand; `[bridge status` reports sweep counters. diff --git a/overlay/Scripts/Custom/Bridge/BridgeProfile.cs b/overlay/Scripts/Custom/Bridge/BridgeProfile.cs new file mode 100644 index 0000000..7e12197 --- /dev/null +++ b/overlay/Scripts/Custom/Bridge/BridgeProfile.cs @@ -0,0 +1,245 @@ +using System; +using System.Text; + +using Server.Accounting; +using Server.Items; +using Server.Mobiles; + +namespace Server.Custom.Bridge +{ + /// + /// Builds the heavy read-models the website consumes: a full character profile, an account + /// roster, and a player's vendor holdings. All read live Mobile/Item state, so all must run + /// on the Core thread — which the inbound dispatch guarantees (BridgeLink marshals every + /// inbound line through Timer.DelayCall before a handler sees it). + /// + /// A profile is the single most expensive read in the bridge (~0.07 ms + ~2.4 KB at the + /// seeded scale, more for a fully-kitted character), so it is built on demand only, never in + /// a sweep. See docs/PLAN.md §1. + /// + public static class BridgeProfile + { + private static readonly AosAttribute[] AllAttrs = + (AosAttribute[])Enum.GetValues(typeof(AosAttribute)); + + private static readonly AosWeaponAttribute[] AllWeaponAttrs = + (AosWeaponAttribute[])Enum.GetValues(typeof(AosWeaponAttribute)); + + private static readonly AosArmorAttribute[] AllArmorAttrs = + (AosArmorAttribute[])Enum.GetValues(typeof(AosArmorAttribute)); + + // ---- full profile ---- + + public static string BuildProfile(PlayerMobile m, string reqId) + { + var sb = BridgeJson.Begin("char.profile"); + + if (reqId != null) + sb.Str("reqId", reqId); + + sb.Ser("serial", m.Serial); + sb.Str("name", m.Name); + sb.Str("title", m.Title); + sb.Num("body", m.Body.BodyID); + sb.Num("hue", m.Hue); + sb.Bool("online", m.NetState != null); + + var acct = m.Account as Account; + if (acct != null) + sb.Str("acct", acct.Username); + + // stats + sb.Append(",\"stats\":{"); + sb.Append("\"str\":").Append(m.Str).Append(",\"dex\":").Append(m.Dex).Append(",\"int\":").Append(m.Int); + sb.Append(",\"hits\":").Append(m.Hits).Append(",\"hitsMax\":").Append(m.HitsMax); + sb.Append(",\"mana\":").Append(m.Mana).Append(",\"manaMax\":").Append(m.ManaMax); + sb.Append(",\"stam\":").Append(m.Stam).Append(",\"stamMax\":").Append(m.StamMax); + sb.Append(",\"fame\":").Append(m.Fame).Append(",\"karma\":").Append(m.Karma); + sb.Append(",\"luck\":").Append(m.Luck); + sb.Append(",\"resist\":{\"phys\":").Append(m.PhysicalResistance); + sb.Append(",\"fire\":").Append(m.FireResistance); + sb.Append(",\"cold\":").Append(m.ColdResistance); + sb.Append(",\"pois\":").Append(m.PoisonResistance); + sb.Append(",\"energy\":").Append(m.EnergyResistance).Append("}}"); + + // skills: trained only (Base > 0), to avoid ~50 zeroes per character + sb.Append(",\"skills\":["); + bool first = true; + for (int i = 0; i < m.Skills.Length; i++) + { + var s = m.Skills[i]; + + if (s == null || s.Base <= 0.0) + continue; + + if (!first) sb.Append(','); + first = false; + + sb.Append("{\"n\":\"").Append(s.SkillName).Append('"'); + sb.Append(",\"base\":").Append(s.Base.ToString("F1")); + sb.Append(",\"value\":").Append(s.Value.ToString("F1")); + sb.Append(",\"cap\":").Append(s.Cap.ToString("F1")); + sb.Append(",\"lock\":\"").Append(s.Lock).Append("\"}"); + } + sb.Append(']'); + + // worn equipment only — not the backpack/bank (see docs/PLAN.md §IV.4) + sb.Append(",\"equipment\":["); + first = true; + foreach (var item in m.Items) + { + if (item == null || !IsGearLayer(item.Layer)) + continue; + + if (!first) sb.Append(','); + first = false; + + WriteItem(sb, item); + } + sb.Append(']'); + + return sb.End(); + } + + private static bool IsGearLayer(Layer layer) + { + switch (layer) + { + case Layer.Backpack: + case Layer.Bank: + case Layer.Hair: + case Layer.FacialHair: + case Layer.Mount: + case Layer.Invalid: + return false; + default: + return true; + } + } + + private static void WriteItem(StringBuilder sb, Item item) + { + sb.Append("{"); + sb.Append("\"serial\":\"0x").Append(item.Serial.Value.ToString("X")).Append('"'); + sb.Append(",\"layer\":\"").Append(item.Layer).Append('"'); + sb.Append(",\"itemId\":").Append(item.ItemID); + sb.Append(",\"hue\":").Append(item.Hue); + sb.Append(",\"cliloc\":").Append(item.LabelNumber); + + if (item.Name != null) + { + sb.Append(",\"name\":"); + BridgeJson.Escape(sb, item.Name); + } + + var weapon = item as BaseWeapon; + var armor = item as BaseArmor; + + if (weapon != null) + { + sb.Append(",\"weapon\":{\"minDamage\":").Append(weapon.MinDamage); + sb.Append(",\"maxDamage\":").Append(weapon.MaxDamage).Append('}'); + } + else if (armor != null) + { + sb.Append(",\"armor\":{\"baseRating\":").Append(armor.BaseArmorRating).Append('}'); + } + + // flattened union of non-zero mods across every attribute bag + sb.Append(",\"mods\":{"); + bool first = true; + + if (weapon != null) + { + WriteAttrs(sb, weapon.Attributes, ref first); + WriteWeaponAttrs(sb, weapon.WeaponAttributes, ref first); + } + else if (armor != null) + { + WriteAttrs(sb, armor.Attributes, ref first); + WriteArmorAttrs(sb, armor.ArmorAttributes, ref first); + } + + sb.Append("}}"); + } + + private static void WriteAttrs(StringBuilder sb, AosAttributes a, ref bool first) + { + if (a == null) return; + + for (int i = 0; i < AllAttrs.Length; i++) + { + int v = a[AllAttrs[i]]; + if (v == 0) continue; + if (!first) sb.Append(','); first = false; + sb.Append('"').Append(AllAttrs[i]).Append("\":").Append(v); + } + } + + private static void WriteWeaponAttrs(StringBuilder sb, AosWeaponAttributes a, ref bool first) + { + if (a == null) return; + + for (int i = 0; i < AllWeaponAttrs.Length; i++) + { + int v = a[AllWeaponAttrs[i]]; + if (v == 0) continue; + if (!first) sb.Append(','); first = false; + sb.Append('"').Append(AllWeaponAttrs[i]).Append("\":").Append(v); + } + } + + private static void WriteArmorAttrs(StringBuilder sb, AosArmorAttributes a, ref bool first) + { + if (a == null) return; + + for (int i = 0; i < AllArmorAttrs.Length; i++) + { + int v = a[AllArmorAttrs[i]]; + if (v == 0) continue; + if (!first) sb.Append(','); first = false; + sb.Append('"').Append(AllArmorAttrs[i]).Append("\":").Append(v); + } + } + + // ---- account roster ---- + + /// + /// Light per-character summary for an account. Offline characters are included: a + /// logged-off mobile stays resident (World.Mobiles) until Delete, so its roster entry is + /// always available. + /// + public static string BuildRoster(Account acct, string reqId) + { + var sb = BridgeJson.Begin("account.roster"); + + if (reqId != null) + sb.Str("reqId", reqId); + + sb.Str("acct", acct.Username); + sb.Append(",\"chars\":["); + + bool first = true; + for (int i = 0; i < acct.Length; i++) + { + var m = acct[i]; + if (m == null) + continue; + + if (!first) sb.Append(','); + first = false; + + sb.Append("{\"slot\":").Append(i); + sb.Append(",\"serial\":\"0x").Append(m.Serial.Value.ToString("X")).Append('"'); + sb.Append(",\"name\":"); + BridgeJson.Escape(sb, m.Name ?? ""); + sb.Append(",\"body\":").Append(m.Body.BodyID); + sb.Append(",\"online\":").Append(m.NetState != null ? "true" : "false"); + sb.Append('}'); + } + sb.Append(']'); + + return sb.End(); + } + } +} diff --git a/overlay/Scripts/Custom/Bridge/BridgeRequests.cs b/overlay/Scripts/Custom/Bridge/BridgeRequests.cs new file mode 100644 index 0000000..e7f277d --- /dev/null +++ b/overlay/Scripts/Custom/Bridge/BridgeRequests.cs @@ -0,0 +1,215 @@ +using System; +using System.Collections.Generic; + +using Server.Accounting; +using Server.Mobiles; +using Server.Multis; + +namespace Server.Custom.Bridge +{ + /// + /// Inbound request/response. The sidecar asks; the shard answers. Every handler runs on the + /// Core thread (BridgeBoot dispatches inbound lines through Timer.DelayCall first), so all of + /// these may read live world state freely. + /// + /// A request carries an optional "reqId" the shard echoes back, so the sidecar can correlate + /// the reply with the request it sent. A malformed or unresolvable request gets a + /// "bridge.error" reply rather than silence, so the website can show a real failure instead + /// of hanging. + /// + public static class BridgeRequests + { + public static void Initialize() + { + if (!BridgeConfig.Enabled) + return; + + BridgeBoot.RegisterHandler("char.request", OnCharRequest); + BridgeBoot.RegisterHandler("account.roster", OnRosterRequest); + BridgeBoot.RegisterHandler("vendor.snapshot", OnVendorSnapshotRequest); + } + + private static void Fail(string reqId, string reason) + { + var sb = BridgeJson.Begin("bridge.error"); + if (reqId != null) + sb.Str("reqId", reqId); + sb.Str("reason", reason); + BridgeLink.Emit(sb.End()); + } + + // ---- char.request ---- + + /// + /// Resolve a character by serial, or by account + slot, and reply with a full profile. + /// Works for offline characters too: a logged-off mobile is still resident. + /// + private static void OnCharRequest(Dictionary o) + { + var reqId = BridgeJson.GetString(o, "reqId"); + + PlayerMobile pm = null; + + var serialStr = BridgeJson.GetString(o, "serial"); + if (serialStr != null) + { + pm = ResolveSerial(serialStr) as PlayerMobile; + + if (pm == null) + { + Fail(reqId, "no player with serial " + serialStr); + return; + } + } + else + { + var acctName = BridgeJson.GetString(o, "account"); + var acct = acctName == null ? null : Accounting.Accounts.GetAccount(acctName) as Account; + + if (acct == null) + { + Fail(reqId, "unknown account"); + return; + } + + int slot = BridgeJson.GetInt(o, "slot", 0); + + if (slot < 0 || slot >= acct.Length) + { + Fail(reqId, "slot out of range"); + return; + } + + pm = acct[slot] as PlayerMobile; + + if (pm == null) + { + Fail(reqId, "no character in slot " + slot); + return; + } + } + + BridgeLink.Emit(BridgeProfile.BuildProfile(pm, reqId)); + } + + // ---- account.roster ---- + + private static void OnRosterRequest(Dictionary o) + { + var reqId = BridgeJson.GetString(o, "reqId"); + var acctName = BridgeJson.GetString(o, "account"); + var acct = acctName == null ? null : Accounting.Accounts.GetAccount(acctName) as Account; + + if (acct == null) + { + Fail(reqId, "unknown account"); + return; + } + + BridgeLink.Emit(BridgeProfile.BuildRoster(acct, reqId)); + } + + // ---- vendor.snapshot ---- + + /// + /// Every player vendor owned by any character on an account, with its held gold and + /// priced listings. Enumerates PlayerVendor.PlayerVendors and matches by owner account. + /// + private static void OnVendorSnapshotRequest(Dictionary o) + { + var reqId = BridgeJson.GetString(o, "reqId"); + var acctName = BridgeJson.GetString(o, "account"); + var acct = acctName == null ? null : Accounting.Accounts.GetAccount(acctName) as Account; + + if (acct == null) + { + Fail(reqId, "unknown account"); + return; + } + + var sb = BridgeJson.Begin("vendor.snapshot"); + if (reqId != null) + sb.Str("reqId", reqId); + sb.Str("acct", acct.Username); + sb.Append(",\"vendors\":["); + + bool firstVendor = true; + var all = PlayerVendor.PlayerVendors; + + if (all != null) + { + foreach (var v in all) + { + if (v == null || v.Deleted || v.Owner == null) + continue; + + if (!(v.Owner.Account is Account ownerAcct) || ownerAcct != acct) + continue; + + if (!firstVendor) sb.Append(','); + firstVendor = false; + + sb.Append("{\"serial\":\"0x").Append(v.Serial.Value.ToString("X")).Append('"'); + sb.Append(",\"shopName\":"); + BridgeJson.Escape(sb, v.ShopName ?? ""); + sb.Append(",\"holdGold\":").Append(v.HoldGold); + sb.Append(",\"ownerSerial\":\"0x").Append(v.Owner.Serial.Value.ToString("X")).Append('"'); + + var house = v.Map; + sb.Append(",\"map\":"); + BridgeJson.Escape(sb, v.Map == null ? "" : v.Map.Name); + sb.Append(",\"x\":").Append(v.X).Append(",\"y\":").Append(v.Y); + + sb.Append(",\"listings\":["); + bool firstItem = true; + var pack = v.Backpack; + + if (pack != null) + { + foreach (var item in pack.Items) + { + var vi = v.GetVendorItem(item); + if (vi == null) + continue; + + if (!firstItem) sb.Append(','); + firstItem = false; + + sb.Append("{\"serial\":\"0x").Append(item.Serial.Value.ToString("X")).Append('"'); + sb.Append(",\"itemId\":").Append(item.ItemID); + sb.Append(",\"amount\":").Append(item.Amount); + sb.Append(",\"price\":").Append(vi.Price); + sb.Append(",\"forSale\":").Append(vi.IsForSale ? "true" : "false").Append('}'); + } + } + sb.Append("]}"); + } + } + + sb.Append(']'); + BridgeLink.Emit(sb.End()); + } + + // ---- helpers ---- + + private static Mobile ResolveSerial(string serialStr) + { + try + { + var s = serialStr.Trim(); + int value; + + if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + value = Convert.ToInt32(s.Substring(2), 16); + else + value = Convert.ToInt32(s, 10); + + return World.FindMobile(value); + } + catch + { + return null; + } + } + } +} diff --git a/tools/stub_sidecar.ps1 b/tools/stub_sidecar.ps1 index 2c65604..8f367f8 100644 --- a/tools/stub_sidecar.ps1 +++ b/tools/stub_sidecar.ps1 @@ -1,31 +1,45 @@ param( [int] $Port = 7788, - [string] $Log = "$PSScriptRoot\sidecar_loop.log" + [string] $Log = "$PSScriptRoot\sc_robust.log" ) -$ErrorActionPreference = 'Stop' -"[sidecar] listening on 127.0.0.1:$Port" | Out-File $Log -Encoding utf8 +# Robust stub sidecar: survives port-in-use from a just-killed instance, and never +# dies on a transient error. Test scaffolding only. + +function Say($msg) { + for ($i = 0; $i -lt 5; $i++) { + try { "$msg" | Out-File -FilePath $Log -Append -Encoding utf8; return } + catch { Start-Sleep -Milliseconds 100 } + } +} + +"" | Out-File -FilePath $Log -Encoding utf8 +Say "[sidecar] starting on 127.0.0.1:$Port" $listener = New-Object System.Net.Sockets.TcpListener([System.Net.IPAddress]::Loopback, $Port) -$listener.Start() +$listener.Server.SetSocketOption('Socket', 'ReuseAddress', $true) + +# Wait for the port to become bindable if a prior instance is still lingering. +$bound = $false +for ($i = 0; $i -lt 30 -and -not $bound; $i++) { + try { $listener.Start(); $bound = $true } + catch { Say "[sidecar] bind retry: $($_.Exception.Message)"; Start-Sleep -Seconds 1 } +} +if (-not $bound) { Say "[sidecar] could not bind $Port; giving up"; exit 1 } + +Say "[sidecar] listening" while ($true) { try { $client = $listener.AcceptTcpClient() - "[sidecar] === shard connected ===" | Add-Content $Log - - $stream = $client.GetStream() - $reader = New-Object System.IO.StreamReader($stream) - - while ($null -ne ($line = $reader.ReadLine())) { - "[sidecar] <- $line" | Add-Content $Log - } - - "[sidecar] === shard disconnected ===" | Add-Content $Log + Say "[sidecar] === shard connected ===" + $reader = New-Object System.IO.StreamReader($client.GetStream()) + while ($null -ne ($line = $reader.ReadLine())) { Say "[sidecar] <- $line" } + Say "[sidecar] === shard disconnected ===" $client.Close() } catch { - "[sidecar] error: $_" | Add-Content $Log - Start-Sleep -Milliseconds 200 + Say "[sidecar] loop error: $($_.Exception.Message)" + Start-Sleep -Milliseconds 300 } } diff --git a/tools/stub_sidecar_request.ps1 b/tools/stub_sidecar_request.ps1 new file mode 100644 index 0000000..c0d18a6 --- /dev/null +++ b/tools/stub_sidecar_request.ps1 @@ -0,0 +1,65 @@ +param( + [int] $Port = 7788, + [string] $Log = "$PSScriptRoot\sc_request.log" +) + +function Say($msg) { + for ($i = 0; $i -lt 5; $i++) { + try { "$msg" | Out-File -FilePath $Log -Append -Encoding utf8; return } + catch { Start-Sleep -Milliseconds 100 } + } +} + +"" | Out-File -FilePath $Log -Encoding utf8 +Say "[req] starting on 127.0.0.1:$Port" + +$listener = New-Object System.Net.Sockets.TcpListener([System.Net.IPAddress]::Loopback, $Port) +$listener.Server.SetSocketOption('Socket', 'ReuseAddress', $true) + +$bound = $false +for ($i = 0; $i -lt 30 -and -not $bound; $i++) { + try { $listener.Start(); $bound = $true } + catch { Start-Sleep -Seconds 1 } +} +if (-not $bound) { Say "[req] could not bind"; exit 1 } + +Say "[req] listening" +$client = $listener.AcceptTcpClient() +Say "[req] === shard connected ===" + +$stream = $client.GetStream() +$reader = New-Object System.IO.StreamReader($stream) +$writer = New-Object System.IO.StreamWriter($stream) +$writer.AutoFlush = $true + +# Give the shard a beat to send its hello, then fire the requests. +Start-Sleep -Milliseconds 500 + +$requests = @( + '{"kind":"account.roster","reqId":"r-roster","account":"whitlocktech"}', + '{"kind":"char.request","reqId":"r-darrow","account":"whitlocktech","slot":0}', + '{"kind":"vendor.snapshot","reqId":"r-vendor","account":"seed_000"}', + '{"kind":"char.request","reqId":"r-bad","account":"does_not_exist","slot":0}', + '{"kind":"char.request","reqId":"r-serial","serial":"0x24C"}' +) + +foreach ($r in $requests) { + $writer.WriteLine($r) + Say "[req] -> $r" + Start-Sleep -Milliseconds 400 +} + +# Read replies for a few seconds. +$deadline = (Get-Date).AddSeconds(8) +while ((Get-Date) -lt $deadline) { + if ($stream.DataAvailable) { + $line = $reader.ReadLine() + if ($null -ne $line) { Say "[req] <- $line" } + } else { + Start-Sleep -Milliseconds 100 + } +} + +Say "[req] done" +$client.Close() +$listener.Stop()