diff --git a/.gitignore b/.gitignore index adf4ba89..05a95010 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ Binaries/* Intermediate/* -Saved/* \ No newline at end of file +Saved/* +# GSD ephemeral research cache (not tracked) +.planning/research/.cache/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..ec858cbc --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,208 @@ +# Changelog + +All notable changes to FicsitRemoteMonitoring are documented here. +Format is loosely based on [Keep a Changelog](https://keepachangelog.com/). + +--- + +## [Unreleased] — Reliability Retrofit + +> **Baseline:** v1.5.2 · **Focus:** reliability of the monitoring channel, not new features. +> A targeted retrofit hardening the HTTP/WebSocket API so it returns accurate, real-time +> game state without crashing or corrupting the dedicated server. +> +> **No new external dependencies.** SML `^3.12.0` compatibility preserved. All public API +> response shapes are unchanged or additive-only (see *Compatibility* below). + +### TL;DR for reviewers + +| Area | What changed | Why it matters | +|------|--------------|----------------| +| **WebSocket threading** | All inbound callbacks and outbound sends are now marshaled through the game thread. | Removes a whole class of race-condition crashes / shared-state corruption. | +| **`getPlayer`** | Crash-safe reads, new `location.lookPitch`, persistent player names across disconnect. | Endpoint no longer crashes the server; richer, more reliable data. | +| **WS request validation** | Malformed/malicious subscribe envelopes are rejected with a clear error *before* dispatch. | Bad input can no longer reach endpoint logic on the now-guaranteed game thread. | +| **Dedicated-server packaging** | Branded 503 fallback when the web UI is missing; icon staging verified against a real packaged build. | Servers without the web UI bundle degrade gracefully instead of breaking silently. | +| **Dev tooling** | Reproducible build/package pipeline, WS stress + validation harnesses, deploy/verify runbook. | Makes the plugin buildable and verifiable on Linux (no test framework exists). | + +Because the project has **no automated test framework**, every runtime change was verified +**live against a deployed Satisfactory dedicated server** (see *Verification* below), not just +by compiling. + +--- + +## Shipping changes (plugin runtime) + +### 1. Thread-safe WebSocket request handling + +**Files:** `Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp`, +`Source/FicsitRemoteMonitoring/Public/FicsitRemoteMonitoring.h` + +**Problem.** Inbound WebSocket callbacks (`open`/`close`/message) and the outbound push loop +touched shared subsystem state — `ConnectedClients`, `EndpointSubscribers`, subscription maps — +directly from the uWS background thread. Concurrent access with the game thread caused +race-condition crashes and state corruption. + +**Change.** +- Introduced generation-tagged client identity: a game-thread-owned `ClientGenerations` map, + loop-thread-only `NextClientIDCounter` / `LoopLiveSockets` bookkeeping, and an + `IsClientCurrent(ws, ClientID)` validity helper — **no new `FWebSocketUserData` field and no + `FCriticalSection`** (correctness comes from thread confinement + generation checks). +- `wsBehavior.open` mints the `ClientID` on the loop thread (where `ws` is provably fresh), + then marshals `ConnectedClients` / `ClientGenerations` registration to the game thread via a + `bShouldStop`-guarded `AsyncTask(GameThread, …)`. Also fixed a pre-existing copy-paste log bug + ("Client Disconnected" was logged on connect). +- `wsBehavior.close` removes `ws` from `LoopLiveSockets` on the loop thread, then a marshaled, + `IsClientCurrent`-guarded game-thread task removes it from `ConnectedClients`, + `ClientGenerations`, and every `EndpointSubscribers` entry. The old `OnClientDisconnected` + path was **retired** and folded in here. +- `ProcessClientRequest` now extracts the action / endpoint list / `(ws, ClientID)` on the loop + thread only, then performs **all** subscribe/unsubscribe mutation inside one + `AsyncTask(GameThread, …)` block, dropping stale/not-yet-registered messages instead of erroring. +- `PushUpdatedData` no longer reads `EndpointSubscribers` or calls `send()` on the push-pacing + thread. It hops to the game thread to build JSON and snapshot recipients (re-validated against + `ClientGenerations`), then **defers each `send()` onto the captured uWS loop** (`CapturedLoop`), + where the callback re-checks `LoopLiveSockets` + the generation tag before sending. Payloads are + copied into owning `std::string`s (no dangling stack buffers). +- `StopWebSocketServer` reworked identically: snapshot recipients on the game thread, defer the + `close()` calls onto `CapturedLoop` with the same liveness guard. `CapturedLoop` is captured + once on the loop thread before `app.run()` and reset to `nullptr` on teardown. +- `bHasRunningPushDataLoop` discipline moved fully onto the game thread. + +**Client impact:** none functionally — message shapes and push behavior are unchanged; the fix is +purely about *where* the work runs. + +--- + +### 2. `getPlayer` endpoint reliability + +**Files:** `Source/FicsitRemoteMonitoring/Private/Endpoints/World/PlayerLibrary.cpp`, +`Source/FicsitRemoteMonitoring/Public/FicsitRemoteMonitoring.h` (name cache), +`Source/FicsitRemoteMonitoring/FicsitRemoteMonitoring.build.cs` (`CoreOnline` module) + +- **Crash-safety:** the two known crash sites — unchecked `GetInventory()->GetInventoryStacks(…)` + and `GetHealthComponent()->GetCurrentHealth()/IsDead()` — are now `IsValid()`-guarded with + fallbacks (`Inventory:[]`, `PlayerHP:0`, `Dead:false`). The `Cast` result is + guarded too (defense in depth). +- **New field:** `location.lookPitch` on every live player entry — signed camera/look pitch from + `AFGPlayerController::GetControlRotation().Pitch`, clamped to `[-90, 90]` (never actor body + rotation). Named `lookPitch` (not `pitch`) so it coexists with upstream's generic actor-rotation + `pitch` field (porisius/FicsitRemoteMonitoring#297) instead of shadowing it: `pitch` = body/actor + pitch (meaningful for vehicles), `lookPitch` = the player's camera/aim pitch (the only pitch + meaningful for an upright player body). +- **Persistent player names:** a session-persistent `PlayerNameCache` (`net-id → name`, keyed by + the stable `APlayerState::GetUniqueId().ToString()`) lets `getPlayer` emit offline players from + cache via a hand-built JSON path that never dereferences a despawned actor. +- **Dedicated-server-safe identity:** names use `APlayerState::GetUniqueId()`, **deliberately not** + `AFGPlayerState::GetUserID()` (which SIGSEGVs on dedicated servers). `CoreOnline` was linked so + `FUniqueNetIdWrapper::ToString()` resolves. + +--- + +### 3. WebSocket request validation (VALD-01) + +**Files:** `Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp`, +`Source/FicsitRemoteMonitoring/Public/FicsitRemoteMonitoring.h` + +- New private `ValidateWSSubscriptionEnvelope(...)` performs strict envelope validation on + subscribe/unsubscribe payloads **before** any endpoint logic runs: `action` present and known; + `endpoints` must be a string or an array of strings (rejects missing/`null`/object/mixed-typed); + names matched **GET-only** against the endpoint registry + (`APIName == Name && Method == "GET"`). Unknown/invalid names are collected and reported with a + **partial-success** contract; the reported-problems list is capped (`MaxReportedProblems = 20` + + `"…and N more"`) so an adversarial all-invalid array can't drive an oversized reflected frame. +- Wired into `ProcessClientRequest` (subscribe path) and `OnMessageReceived` (JSON parse-failure). + Rejected input returns an `{"error": …}` frame and **never reaches dispatch or shared-state + mutation**; the socket stays open and usable. +- The HTTP `CallEndpoint` path is untouched (zero diff) — no regression for existing REST clients. + +--- + +### 4. Dedicated-server asset packaging & fallback page + +**Files:** `Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp` (catch-all + +`/Icons/*` serving), `Source/FicsitRemoteMonitoring/Private/FRM_Request.cpp` + +`Source/FicsitRemoteMonitoring/Public/FRM_Request.h` (`SendFallbackPage`), +`Config/FilterPlugin.ini` (inert marker) + +- **Branded 503 fallback (PKG-02):** when the web UI bundle (`www/`) isn't deployed — common on + dedicated servers — a page navigation now returns a branded `503` HTML page + (`SendFallbackPage`) pointing at `/api/` and the docs, instead of a bare error. The body is a + fixed compile-time string (no request data echoed → no injection surface). +- **Carve-outs:** the fallback fires **only** for extensionless / `.html` / `.htm` page requests + **and** only when the path does not name a registered endpoint. Sub-resources (`.js/.css/.avif/ + .woff2`), `/Icons/*`, and `/api/*` keep their normal `404`/JSON content-type contract. +- **Bare-form API guard (CR-01):** the catch-all consults the endpoint registry first, so + extensionless API calls (e.g. `/getWorldInv`, `/getModList`) keep returning **JSON**, not the + fallback page, even while `www/` is missing. +- **Icon staging (PKG-01):** confirmed that `Config/PluginSettings.ini` + (`[StageSettings] +AdditionalNonUSFDirectories=Icons`) is the real mechanism that stages the + `Icons/` directory into a packaged build — **verified against a real LinuxServer zip**, not just + in-editor. `Config/FilterPlugin.ini` was marked *inert* with a comment so no maintainer mistakes + it for the packaging lever. `PluginSettings.ini` itself was **not modified** (already correct). +- Fixed an MSVC-only unity-build compile break (`C4459`: a `SendFallbackPage` parameter shadowed a + file-scope `DocsURL`), which only surfaced on the Windows client target during full packaging. + +--- + +## Developer tooling & repo scaffolding (non-shipping) + +None of these ship in the packaged plugin; they make it buildable and verifiable. + +- **`build/package.sh`** — reproducible `compile` / `package` wrapper around the verified + RunUBT / RunUAT (`PackagePlugin`) invocations (Win64 client + Linux server, Shipping). +- **`build/RUNBOOK.md`** — deploy/verify runbook: WS stress procedure, PKG-02 fallback smoke + battery, and the icon release workflow (generate on a client → copy into `Icons/` → package → + verify via `unzip -l`). +- **`build/tools/ws-stress-harness.mjs`** — zero-dependency Node script driving N concurrent WS + clients through connect→subscribe→unsubscribe→disconnect churn; exits non-zero on crash/leak. +- **`build/tools/ws-validation-probe.mjs`** — zero-dependency Node probe encoding the VALD-01 + malformed-payload cases; exits non-zero on any validation regression. +- **`.gitignore`** — ignore the ephemeral planning research cache. + +--- + +## Compatibility + +**Deliberately unchanged:** +- `Config/PluginSettings.ini` — already staged `Icons`/`www` correctly. +- HTTP `CallEndpoint` dispatch path — no behavior change for REST clients. +- SML `^3.12.0` dependency; **no new external C++/JS libraries** (uses already-linked Unreal `Json` + and the vendored uWebSockets). + +**Additive-only API changes** (no breaking changes to existing consumers): +- `getPlayer` entries gain `location.lookPitch` (camera/aim pitch; distinct from the generic actor + `pitch` field added upstream in porisius/FicsitRemoteMonitoring#297). +- `getPlayer` may now include offline players (from the persistent name cache). +- WebSocket subscribe/unsubscribe now returns an `{"error": …}` frame for malformed envelopes + (previously undefined behavior); well-formed requests are unaffected. + +--- + +## Verification + +No automated test framework exists in this project (by design — see `.claude/CLAUDE.md`). +Each phase was verified **live on a deployed Satisfactory Linux dedicated server**: + +- **Threading:** `ws-stress-harness.mjs` under connection churn — clean closes, `Remaining + connections: 0` after each cycle, no crash/leak. +- **Validation:** `ws-validation-probe.mjs` — 8/8 malformed-payload cases rejected correctly; + 25-client churn showed no regression for valid clients. +- **Packaging:** real `LinuxServer.zip` inspected via `unzip -l` (Icons/ staged); PKG-02 six-case + curl battery (503 fallback + carve-outs + bare-form JSON guard) all green. +- Per-phase `*-VERIFICATION.md` and `*-SECURITY.md` (threats_open: 0) accompany the work in + `.planning/phases/`. + +--- + +## Reviewer file map + +| File | Area | Nature | +|------|------|--------| +| `Source/…/FicsitRemoteMonitoring.cpp` | threading + validation + fallback/Icons serving | core logic (+506/−92) | +| `Source/…/FicsitRemoteMonitoring.h` | declarations, `ClientGenerations`, `PlayerNameCache`, validator | header | +| `Source/…/Endpoints/World/PlayerLibrary.cpp` | `getPlayer` hardening + `location.lookPitch` + offline names | endpoint (+191) | +| `Source/…/FRM_Request.cpp` / `.h` | `SendFallbackPage` (503 page) | helper | +| `Source/…/FicsitRemoteMonitoring.build.cs` | `+CoreOnline` module | build | +| `Config/FilterPlugin.ini` | inert marker comment | config (no functional effect) | +| `build/package.sh`, `build/RUNBOOK.md`, `build/tools/*.mjs` | build + verification tooling | non-shipping | +| `.gitignore` | repo scaffolding | non-shipping | diff --git a/Config/FilterPlugin.ini b/Config/FilterPlugin.ini index ccebca2f..7711b585 100644 --- a/Config/FilterPlugin.ini +++ b/Config/FilterPlugin.ini @@ -6,3 +6,9 @@ ; /README.txt ; /Extras/... ; /Binaries/ThirdParty/*.dll +; +; INERT FOR THIS PROJECT'S PACKAGING PIPELINE: build/package.sh runs Alpakit's PackagePlugin +; (which extends BuildCookRun), and BuildCookRun never reads FilterPlugin.ini. Raw folders +; (Icons, www, JSON, Resources, Debug) are actually bundled into the packaged artifact via +; Config/PluginSettings.ini's [StageSettings] +AdditionalNonUSFDirectories= entries, which +; already list Icons and www. Adding entries here has zero effect on the packaged zip. diff --git a/Source/FicsitRemoteMonitoring/FicsitRemoteMonitoring.build.cs b/Source/FicsitRemoteMonitoring/FicsitRemoteMonitoring.build.cs index 4d518a8f..edbebb4c 100644 --- a/Source/FicsitRemoteMonitoring/FicsitRemoteMonitoring.build.cs +++ b/Source/FicsitRemoteMonitoring/FicsitRemoteMonitoring.build.cs @@ -40,6 +40,7 @@ public FicsitRemoteMonitoring(ReadOnlyTargetRules Target) : base(Target) new string[] { "Core", "CoreUObject", + "CoreOnline", "Engine", "InputCore", "Json", diff --git a/Source/FicsitRemoteMonitoring/Private/Endpoints/World/PlayerLibrary.cpp b/Source/FicsitRemoteMonitoring/Private/Endpoints/World/PlayerLibrary.cpp index 863c5ee8..102dad08 100644 --- a/Source/FicsitRemoteMonitoring/Private/Endpoints/World/PlayerLibrary.cpp +++ b/Source/FicsitRemoteMonitoring/Private/Endpoints/World/PlayerLibrary.cpp @@ -2,42 +2,211 @@ #include "FGCharacterPlayer.h" #include "FGCreatureSubsystem.h" +#include "FGPlayerController.h" +#include "FGPlayerState.h" #include "FGSporeFlower.h" #include "FicsitRemoteMonitoring.h" #include "RemoteMonitoringLibrary.h" #include "Kismet/GameplayStatics.h" +namespace { + // Hand-built offline player entry (PLYR-03/D-01). The shared JSON helpers + // (CreateBaseJsonObject / getActorJSON / getActorFeaturesJSON) all dereference + // their actor argument unconditionally on the first line, so a disconnected + // player (no actor) must be assembled field-by-field here instead of calling + // them with a null/placeholder actor (03-RESEARCH.md Pattern 3 / Pitfall 2). + // + // Default convention (documented for the SUMMARY): no last-known state is + // cached or replayed — every live-only field gets a neutral zero/false/empty + // default, matching the live entry's field names/types exactly. Name is the + // only field sourced from real data (the connect-time cache). + TSharedPtr BuildOfflinePlayerJson(const FString& UserID, const FString& CachedName) { + TSharedPtr JPlayer = MakeShared(); + JPlayer->Values.Add("ID", MakeShared(UserID)); + JPlayer->Values.Add("Name", MakeShared(CachedName)); + JPlayer->Values.Add("ClassName", MakeShared(AFGCharacterPlayer::StaticClass()->GetName())); + + TSharedPtr Location = MakeShared(); + Location->Values.Add("x", MakeShared(0)); + Location->Values.Add("y", MakeShared(0)); + Location->Values.Add("z", MakeShared(0)); + Location->Values.Add("rotation", MakeShared(0)); + Location->Values.Add("lookPitch", MakeShared(0)); + JPlayer->Values.Add("location", MakeShared(Location)); + + JPlayer->Values.Add("Speed", MakeShared(0)); + JPlayer->Values.Add("Online", MakeShared(false)); + JPlayer->Values.Add("PlayerHP", MakeShared(0)); + JPlayer->Values.Add("Dead", MakeShared(false)); + JPlayer->Values.Add("Inventory", MakeShared(TArray>{})); + + // features: mirror getActorFeaturesJSON's shape (RemoteMonitoringLibrary.cpp:215-244) + // field-for-field, with zeroed/defaulted coordinates instead of a live actor location. + TSharedPtr Properties = MakeShared(); + Properties->Values.Add("name", MakeShared(CachedName)); + Properties->Values.Add("type", MakeShared(TEXT("Player"))); + + TSharedPtr Coordinates = MakeShared(); + Coordinates->Values.Add("x", MakeShared(0)); + Coordinates->Values.Add("y", MakeShared(0)); + Coordinates->Values.Add("z", MakeShared(0)); + + TSharedPtr Geometry = MakeShared(); + Geometry->Values.Add("coordinates", MakeShared(Coordinates)); + Geometry->Values.Add("type", MakeShared(TEXT("Point"))); + + TSharedPtr Features = MakeShared(); + Features->Values.Add("properties", MakeShared(Properties)); + Features->Values.Add("geometry", MakeShared(Geometry)); + JPlayer->Values.Add("features", MakeShared(Features)); + + return JPlayer; + } +} + void UPlayerLibrary::getPlayer(UObject* WorldContext, FRequestData RequestData, TArray>& OutJsonArray) { TArray FoundActors; + // Dedupe sets so the offline enumeration loop (PlayerNameCache) never emits a + // player already produced by the live loop — "exactly one entry" (PLYR-03/Pitfall 4). + // - VisitedIDs: stable net-ids of ONLINE players (net-id readable only while online). + // - VisitedNames: display names of ALL live actors, including a DISCONNECTED-but- + // persisted player whose actor lingers in the world with its name but has lost its + // net-id. On this dedicated server disconnected players keep their character actor, + // so the live loop already shows them; without the name check the cache would emit a + // second, net-id-keyed copy of the same player. The name is the one identifier that + // is consistent across the live-actor and cache representations. + TSet VisitedIDs; + TSet VisitedNames; + + // Subsystem pointer used both for the opportunistic in-loop cache refresh and + // the offline-enumeration loop after it. IsValid()-guarded (never fgcheck) — + // the subsystem can legitimately be null during world teardown/startup. + AFicsitRemoteMonitoring* ModSubsystem = AFicsitRemoteMonitoring::Get(WorldContext->GetWorld()); + const bool bHasValidSubsystem = IsValid(ModSubsystem); + UGameplayStatics::GetAllActorsOfClass(WorldContext->GetWorld(), AFGCharacterPlayer::StaticClass(), FoundActors); for (AActor* Player : FoundActors) { TSharedPtr JPlayer = CreateBaseJsonObject(Player); AFGCharacterPlayer* PlayerCharacter = Cast(Player); - // get player inventory - TArray InventoryStacks; - PlayerCharacter->GetInventory()->GetInventoryStacks(InventoryStacks); - TMap, int32> PlayerInventory = GetGroupedInventoryItems(InventoryStacks); - - //TODO: Find way to get player's name when they are offline FString PlayerName = GetPlayerName(PlayerCharacter); + // Every live actor (online or disconnected-but-persisted) suppresses a same-name + // cache duplicate in the offline loop below. + if (!PlayerName.IsEmpty()) { + VisitedNames.Add(PlayerName); + } + + // Safe defaults for every field that depends on a component that can + // legitimately be null while a player is mid-disconnect (PLYR-02/D-02). + TArray> InventoryJsonArray; + float Speed = 0.0f; + bool bOnline = false; + float PlayerHP = 0.0f; + bool bDead = false; + + // Camera/look pitch (PLYR-01/D-03), signed -90..90, sourced from the + // PlayerController's control rotation — NOT actor body rotation. + // Defaults to 0.0 when there is no valid controller (mid-disconnect). + float Pitch = 0.0f; + + if (IsValid(PlayerCharacter)) { + Speed = PlayerCharacter->GetVelocity().Length() * 0.036; + bOnline = PlayerCharacter->IsPlayerOnline(); + + UFGInventoryComponent* PlayerInventoryComponent = PlayerCharacter->GetInventory(); + if (IsValid(PlayerInventoryComponent)) { + TArray InventoryStacks; + PlayerInventoryComponent->GetInventoryStacks(InventoryStacks); + TMap, int32> PlayerInventory = GetGroupedInventoryItems(InventoryStacks); + InventoryJsonArray = GetInventoryJSON(PlayerInventory); + } + + UFGHealthComponent* PlayerHealthComponent = PlayerCharacter->GetHealthComponent(); + if (IsValid(PlayerHealthComponent)) { + PlayerHP = PlayerHealthComponent->GetCurrentHealth(); + bDead = PlayerHealthComponent->IsDead(); + } + + AFGPlayerController* PlayerController = PlayerCharacter->GetFGPlayerController(); + if (IsValid(PlayerController)) { + float RawPitch = PlayerController->GetControlRotation().Pitch; + if (RawPitch > 180.f) { + RawPitch -= 360.f; + } + Pitch = FMath::Clamp(RawPitch, -90.f, 90.f); + } + + // Stable-ID keying + dedupe (Pitfall 4) + name-cache population: record this + // player's stable online ID so the offline enumeration loop below skips them, + // and cache the name keyed by that ID so it survives disconnect (PLYR-03/D-04). + // + // Crash-safe stable ID: use the ENGINE accessor APlayerState::GetUniqueId() + // (returns the FUniqueNetIdRepl member by ref) guarded by FUniqueNetIdRepl:: + // IsValid() + ToString(), BOTH of which null-check the underlying net-id + // internally (Engine/CoreOnline.h). This deliberately replaces FactoryGame's + // AFGPlayerState::GetUserID(), which dereferences the net-id via the asserting + // operator-> WITHOUT an IsValid() check and SIGSEGVs on a dedicated server + // whenever the id is not populated. When no valid net id is available, ToString + // yields empty and we simply skip caching-by-id — never crash. + const APlayerState* PlayerStateBase = PlayerCharacter->GetPlayerState(); + if (IsValid(PlayerStateBase)) { + const FUniqueNetIdRepl& NetId = PlayerStateBase->GetUniqueId(); + if (NetId.IsValid()) { + const FString UserID = NetId.ToString(); + if (!UserID.IsEmpty()) { + VisitedIDs.Add(UserID); + if (bHasValidSubsystem) { + ModSubsystem->PlayerNameCache.Add(UserID, PlayerName); + } + } + } + } + } + + // getPlayer-local injection into the object getActorJSON returns — the + // shared helper itself is NOT modified (D-03 blast-radius limit). Named + // "lookPitch" (not "pitch") so it coexists with getActorJSON's generic + // actor-rotation pitch instead of shadowing it: "pitch" = body/actor + // pitch (meaningful for vehicles), "lookPitch" = the player's camera/aim + // pitch (the only pitch that is meaningful for an upright player body). + TSharedPtr LocationJson = getActorJSON(Player); + LocationJson->Values.Add("lookPitch", MakeShared(Pitch)); + JPlayer->Values.Add("Name", MakeShared(PlayerName)); JPlayer->Values.Add("ClassName", MakeShared(Player->GetClass()->GetName())); - JPlayer->Values.Add("location", MakeShared(getActorJSON(Player))); - //JPlayer->Values.Add("PlayerID", MakeShared(PlayerState->GetUserID())); - JPlayer->Values.Add("Speed", MakeShared(PlayerCharacter->GetVelocity().Length() * 0.036)); - JPlayer->Values.Add("Online", MakeShared(PlayerCharacter->IsPlayerOnline())); - JPlayer->Values.Add("PlayerHP", MakeShared(PlayerCharacter->GetHealthComponent()->GetCurrentHealth())); - JPlayer->Values.Add("Dead", MakeShared(PlayerCharacter->GetHealthComponent()->IsDead())); - JPlayer->Values.Add("Inventory", MakeShared(GetInventoryJSON(PlayerInventory))); + JPlayer->Values.Add("location", MakeShared(LocationJson)); + JPlayer->Values.Add("Speed", MakeShared(Speed)); + JPlayer->Values.Add("Online", MakeShared(bOnline)); + JPlayer->Values.Add("PlayerHP", MakeShared(PlayerHP)); + JPlayer->Values.Add("Dead", MakeShared(bDead)); + JPlayer->Values.Add("Inventory", MakeShared(InventoryJsonArray)); JPlayer->Values.Add("features", MakeShared(getActorFeaturesJSON(Player, PlayerName, "Player"))); OutJsonArray.Add(MakeShared(JPlayer)); }; + + // Offline enumeration (PLYR-03/D-01): every cached player NOT visited by the + // live loop above gets a hand-built offline entry (Pattern 3) — never via + // CreateBaseJsonObject/getActorJSON/getActorFeaturesJSON, which would + // dereference a null actor. + if (bHasValidSubsystem) { + for (const TPair& CachedPlayer : ModSubsystem->PlayerNameCache) { + const FString& UserID = CachedPlayer.Key; + const FString& CachedName = CachedPlayer.Value; + // Skip if the same player is already shown by the live loop — matched by + // net-id (online) OR by display name (disconnected-but-persisted actor, + // whose net-id is no longer readable). Prevents the offline duplicate. + if (VisitedIDs.Contains(UserID) || VisitedNames.Contains(CachedName)) { + continue; + } + + OutJsonArray.Add(MakeShared(BuildOfflinePlayerJson(UserID, CachedName))); + } + } }; void UPlayerLibrary::getDoggo(UObject* WorldContext, FRequestData RequestData, TArray>& OutJsonArray) { diff --git a/Source/FicsitRemoteMonitoring/Private/FRM_Request.cpp b/Source/FicsitRemoteMonitoring/Private/FRM_Request.cpp index e23839dd..e2cd889d 100644 --- a/Source/FicsitRemoteMonitoring/Private/FRM_Request.cpp +++ b/Source/FicsitRemoteMonitoring/Private/FRM_Request.cpp @@ -22,6 +22,22 @@ void UFRM_RequestLibrary::SendErrorMessage(uWS::HttpResponse* res, const SendErrorJson(res, Status, JsonObjectToString(JsonObject, false)); } +void UFRM_RequestLibrary::SendFallbackPage(uWS::HttpResponse* res, const FString& InDocsURL) +{ + const FString Html = FString::Printf(TEXT( + "FRM - Web UI Unavailable" + "

FicsitRemoteMonitoring: Web UI files not found

" + "

This is common on dedicated servers when the web UI bundle was not deployed alongside " + "the mod. The monitoring API itself is still live at /api/.

" + "

See the setup documentation for how to deploy the web UI.

" + ""), *InDocsURL); + + res->writeStatus("503 Service Unavailable"); + res->writeHeader("Content-Type", "text/html"); + AddResponseHeaders(res, false); // false: Content-Type already set above — do not pass true (see Pitfall 3) + res->end(TCHAR_TO_UTF8(*Html)); +} + void UFRM_RequestLibrary::AddResponseHeaders(uWS::HttpResponse* res, const bool bIncludeContentType) { res diff --git a/Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp b/Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp index 6b0350a3..3ae0d9c6 100644 --- a/Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp +++ b/Source/FicsitRemoteMonitoring/Private/FicsitRemoteMonitoring.cpp @@ -37,6 +37,9 @@ us_listen_socket_t* SocketListener; bool SocketRunning = false; +// PKG-02: fallback-page docs link, matches FicsitRemoteMonitoring.uplugin's DocsURL field. +static const FString DocsURL = TEXT("https://docs.ficsit.app/ficsitremotemonitoring/latest/index.html"); + AFicsitRemoteMonitoring* AFicsitRemoteMonitoring::Get(UWorld* WorldContext) { for (TActorIterator It(WorldContext, StaticClass(), EActorIteratorFlags::AllActors); It; ++It) { @@ -64,7 +67,7 @@ void AFicsitRemoteMonitoring::BeginPlay() // Load FRM's API Endpoints InitAPIRegistry(); - + const FString AuthToken = UFRMConfigManager::GetConfigOrDefault(TEXT("uWS.AuthenticationToken"), ""); // Debug log to verify token retrieval -Porisius @@ -102,10 +105,15 @@ void AFicsitRemoteMonitoring::BeginPlay() void AFicsitRemoteMonitoring::StartWebSocketPushDataLoop() { if (bHasRunningPushDataLoop) return; - + + // This function is only ever invoked from ProcessClientRequest's marshaled game-thread subscribe + // branch, so this read/write of bHasRunningPushDataLoop is already game-thread owned. Set it here, + // synchronously, before spawning the push-pacing thread (D-04: all writes/reads of this flag must be + // game-thread owned end-to-end — do not move this into the Async(Thread,...) lambda below). + bHasRunningPushDataLoop = true; + Async(EAsyncExecution::Thread, [this]() { - bHasRunningPushDataLoop = true; UE_LOGFMT(LogHttpServer, Log, "Starting PushUpdatedData loop"); while (SocketRunning && !bShouldStop) { @@ -115,7 +123,17 @@ void AFicsitRemoteMonitoring::StartWebSocketPushDataLoop() FPlatformProcess::Sleep(PushCycle); } UE_LOGFMT(LogHttpServer, Log, "Stopped PushUpdatedData loop"); - bHasRunningPushDataLoop = false; + + // Marshal the terminal write back to the game thread (D-04) instead of writing it directly from + // this push-pacing thread. + TWeakObjectPtr WeakThis(this); + AsyncTask(ENamedThreads::GameThread, [WeakThis]() + { + if (AFicsitRemoteMonitoring* Self = WeakThis.Get()) + { + Self->bHasRunningPushDataLoop = false; + } + }); }); } @@ -144,10 +162,46 @@ void AFicsitRemoteMonitoring::StopWebSocketServer() SocketListener = nullptr; UE_LOG(LogHttpServer, Log, TEXT("Closing all %d connections"), ConnectedClients.Num()); - for (const auto ConnectedClient : ConnectedClients) + + // D-04 shutdown safety / resolves RESEARCH Open Question #1: ws->close() is a socket-owning call, + // the same thread-ownership violation class as PushUpdatedData's send() — it must run on the uWS + // loop thread, not here on the game thread. Snapshot (ws, ClientID) pairs now (game-thread-owned + // ConnectedClients/ClientGenerations are still safe to read here), then defer the actual close() + // onto the loop thread; the deferred callback re-validates against LoopLiveSockets before + // touching ws. Safe no-op if CapturedLoop is already null (loop thread already torn down). + if (CapturedLoop) { - ConnectedClient->close(); + TArray*, int32>> ClientsToClose; + ClientsToClose.Reserve(ConnectedClients.Num()); + + for (const auto ConnectedClient : ConnectedClients) + { + if (const int32* FoundClientID = ClientGenerations.Find(ConnectedClient)) + { + ClientsToClose.Emplace(ConnectedClient, *FoundClientID); + } + } + + TWeakObjectPtr WeakThis(this); + CapturedLoop->defer([WeakThis, ClientsToClose]() + { + AFicsitRemoteMonitoring* Self = WeakThis.Get(); + if (!Self) + { + return; + } + + for (const auto& ClientPair : ClientsToClose) + { + const int32* FoundClientID = Self->LoopLiveSockets.Find(ClientPair.Key); + if (FoundClientID && *FoundClientID == ClientPair.Value) + { + ClientPair.Key->close(); + } + } + }); } + ConnectedClients.Empty(); } @@ -201,6 +255,12 @@ void AFicsitRemoteMonitoring::StartWebSocketServer(bool bSkipIfRunning) WebServer = Async(EAsyncExecution::Thread, [this]() { try { auto app = uWS::App(); + + // Captured once, on the uWS loop thread (uWS::Loop::get() is thread-local — calling it + // from any other thread returns a different, unrelated loop). Consumed by PushUpdatedData + // and StopWebSocketServer to defer() outbound send()/close() onto this thread (THRD-02). + CapturedLoop = uWS::Loop::get(); + auto World = GetWorld(); const int32 port = UFRMConfigManager::GetConfigOrDefault(TEXT("uWS.Port"), 8080); @@ -223,11 +283,40 @@ void AFicsitRemoteMonitoring::StartWebSocketServer(bool bSkipIfRunning) wsBehavior.compression = uWS::SHARED_COMPRESSOR; - // Close handler (for when a client disconnects) + // Close handler (for when a client disconnects). Runs on the uWS loop thread; per uWS's + // open->close validity guarantee, ws and its userdata are still safely dereferenceable here + // (closeHandler fires before ~WebSocketData()). This is the last safe touch of ws on this + // thread. All shared-state mutation (ConnectedClients/EndpointSubscribers/ClientGenerations) + // is marshaled to the game thread and re-validated by generation tag (THRD-01, D-02/D-03). wsBehavior.close = [this](uWS::WebSocket* ws, int code, std::string_view message) { - ConnectedClients.Remove(ws); - UE_LOG(LogHttpServer, Log, TEXT("Client Disconnected. Remaining connections: %d"), ConnectedClients.Num()); - OnClientDisconnected(ws, code, message); // Ensure this signature matches + const int32 ClosedClientID = ws->getUserData()->ClientID; + + // Loop-thread-only bookkeeping, consumed by the outbound deferred-send path (Plan 03). + LoopLiveSockets.Remove(ws); + + TWeakObjectPtr WeakThis(this); + AsyncTask(ENamedThreads::GameThread, [WeakThis, ws, ClosedClientID]() + { + AFicsitRemoteMonitoring* Self = WeakThis.Get(); + if (!Self || Self->bShouldStop) return; // shutdown/teardown race guard (D-04) + + // Re-validate against the generation map (D-03) rather than trusting submission + // order (Pitfall 2) — a stale/reused ws must not mutate the wrong client's state. + if (Self->IsClientCurrent(ws, ClosedClientID)) + { + Self->ConnectedClients.Remove(ws); + Self->ClientGenerations.Remove(ws); + + // Folded from the retired OnClientDisconnected: remove ws from every + // subscription so no EndpointSubscribers mutation remains on the loop thread. + for (auto& Elem : Self->EndpointSubscribers) + { + Elem.Value.Remove(ws); + } + } + + UE_LOG(LogHttpServer, Log, TEXT("Client Disconnected. Remaining connections: %d"), Self->ConnectedClients.Num()); + }); }; // Message handler (for when a client sends a message) @@ -235,10 +324,26 @@ void AFicsitRemoteMonitoring::StartWebSocketServer(bool bSkipIfRunning) OnMessageReceived(ws, message, opCode); // Make sure this signature matches }; + // Open handler (for when a client connects). Runs on the uWS loop thread; ws is provably + // valid here (freshly opened). Mint the ClientID generation tag synchronously (safe: this is + // the loop-thread-only counter/bookkeeping), then marshal the registry mutation to the game + // thread — fire-and-forget, no spin-wait (D-02). wsBehavior.open = [this](uWS::WebSocket* ws) { - ConnectedClients.Add(ws); - UE_LOG(LogHttpServer, Log, TEXT("Client Disconnected. Connections: %d"), ConnectedClients.Num()); + const int32 NewClientID = NextClientIDCounter++; + ws->getUserData()->ClientID = NewClientID; + LoopLiveSockets.Add(ws, NewClientID); + + TWeakObjectPtr WeakThis(this); + AsyncTask(ENamedThreads::GameThread, [WeakThis, ws, NewClientID]() + { + AFicsitRemoteMonitoring* Self = WeakThis.Get(); + if (!Self || Self->bShouldStop) return; // shutdown/teardown race guard (D-04) + + Self->ConnectedClients.Add(ws); + Self->ClientGenerations.Add(ws, NewClientID); // ws used only as an opaque map key + UE_LOG(LogHttpServer, Log, TEXT("Client Connected. Connections: %d"), Self->ConnectedClients.Num()); + }); }; app.get("/getCoffee", [this](auto* res, auto* req) { @@ -391,9 +496,22 @@ void AFicsitRemoteMonitoring::StartWebSocketServer(bool bSkipIfRunning) HandleGetRequest(res, req, FilePath); } else { - FRequestData RequestData; - RequestData.bIsAuthorized = IsAuthorizedRequest(req, AuthToken); - HandleApiRequest(World, res, req, RelativePath, RequestData); + FString ReqExt = FPaths::GetExtension(RelativePath).ToLower(); + // Bare-form API endpoints (e.g. "getWorldInv") are also extensionless, so the + // extension alone cannot tell a web UI page navigation apart from an API call. + // Only serve the branded fallback when the request looks like a page AND does not + // name a registered endpoint; anything that maps to an endpoint (or a mistyped + // endpoint name) keeps its existing JSON contract via HandleApiRequest. + bool bIsPageRequest = (ReqExt.IsEmpty() || ReqExt == "html" || ReqExt == "htm") + && !IsRegisteredEndpointName(RelativePath); + if (bIsPageRequest) { + UFRM_RequestLibrary::SendFallbackPage(res, DocsURL); + } + else { + FRequestData RequestData; + RequestData.bIsAuthorized = IsAuthorizedRequest(req, AuthToken); + HandleApiRequest(World, res, req, RelativePath, RequestData); + } } }); @@ -432,6 +550,11 @@ void AFicsitRemoteMonitoring::StartWebSocketServer(bool bSkipIfRunning) } catch (...) { UE_LOG(LogHttpServer, Error, TEXT("Unknown Exception in WebSocket Server")); } + + // Teardown safety (RESEARCH Open Question #2): clear the captured loop pointer once this + // thread's app.run() returns (normally or via exception) so no push-pacing/game-thread caller + // can defer() onto a stale/torn-down loop after this thread exits. + CapturedLoop = nullptr; }); } @@ -473,11 +596,12 @@ std::unordered_map ParseQueryString(const std::string& return QueryPairs; } -void AFicsitRemoteMonitoring::OnClientDisconnected(uWS::WebSocket* ws, int code, std::string_view message) { - // Remove the client from all endpoint subscriptions - for (auto& Elem : EndpointSubscribers) { - Elem.Value.Remove(ws); - } +bool AFicsitRemoteMonitoring::IsClientCurrent(uWS::WebSocket* ws, int32 ClientID) const +{ + // Game-thread-only. ws is used strictly as an opaque map key here — never dereferenced — so this is + // safe to call even for a ws that has since been closed/freed/reused (D-03 generation-tag defense). + const int32* FoundClientID = ClientGenerations.Find(ws); + return FoundClientID != nullptr && *FoundClientID == ClientID; } void AFicsitRemoteMonitoring::OnMessageReceived(uWS::WebSocket* ws, std::string_view message, uWS::OpCode opCode) { @@ -495,92 +619,206 @@ void AFicsitRemoteMonitoring::OnMessageReceived(uWS::WebSocketsend() here needs no Loop::defer()/TWeakObjectPtr hop. + const FString ErrorJson = UFRM_RequestLibrary::JsonObjectToString( + UFRM_RequestLibrary::GenerateError(TEXT("Malformed request: payload is not valid JSON.")), /*JSONDebugMode=*/false); + FTCHARToUTF8 Converted(*ErrorJson); + ws->send(std::string_view(Converted.Get(), Converted.Length()), uWS::OpCode::TEXT); } } void AFicsitRemoteMonitoring::ProcessClientRequest(uWS::WebSocket* ws, const TSharedPtr& JsonRequest) { - FString Action = JsonRequest->GetStringField(TEXT("action")); - const TArray>* EndpointsArray; - FString Endpoint; + // Loop-thread-only: extract plain values while ws is still safely dereferenceable — this runs + // synchronously inside wsBehavior.message's own call stack (per uWS's open->close validity + // guarantee). No shared-state mutation happens here; the actual EndpointSubscribers/ + // bHasRunningPushDataLoop work is marshaled to the game thread below (THRD-01). + const int32 RequestClientID = ws->getUserData()->ClientID; + + FString Action; + TArray ValidNames; + FString ErrorMessage; + + // VALD-01: strict envelope validation (action, endpoints shape/type, GET-only registry match) + // runs synchronously here, before the AsyncTask(GameThread) hop below — see + // ValidateWSSubscriptionEnvelope for the strict TryGet*-family + EJson::String checks that + // replace the previous silent-coercion GetStringField/AsString reads. + const bool bWholeRequestValid = ValidateWSSubscriptionEnvelope(JsonRequest, Action, ValidNames, ErrorMessage); + + if (!ErrorMessage.IsEmpty()) + { + // D-02: direct, synchronous send — no Loop::defer()/TWeakObjectPtr hop needed here, since + // this code is not crossing a thread boundary (unlike PushUpdatedData, which originates on + // the push-pacing thread). Never ws->close() on validation failure — the connection stays + // open (D-02) regardless of whether this was a whole-request reject or a partial-success + // report (D-03). + const FString ErrorJson = UFRM_RequestLibrary::JsonObjectToString( + UFRM_RequestLibrary::GenerateError(ErrorMessage), /*JSONDebugMode=*/false); + FTCHARToUTF8 Converted(*ErrorJson); + ws->send(std::string_view(Converted.Get(), Converted.Length()), uWS::OpCode::TEXT); + } - if (JsonRequest->TryGetArrayField(TEXT("endpoints"), EndpointsArray)) + if (!bWholeRequestValid || ValidNames.Num() == 0) return; // nothing valid left to mutate + + TWeakObjectPtr WeakThis(this); + AsyncTask(ENamedThreads::GameThread, [WeakThis, ws, RequestClientID, Action, ValidNames]() { - for (const TSharedPtr& EndpointValue : *EndpointsArray) - { - Endpoint = EndpointValue->AsString(); + AFicsitRemoteMonitoring* Self = WeakThis.Get(); + if (!Self || Self->bShouldStop) return; // shutdown/teardown race guard (D-04) + // Drop stale/not-yet-registered messages rather than treat as an error (Assumption A2): a + // message for a ws whose ClientID isn't (or is no longer) in ClientGenerations is a valid + // ignorable state, not a crash — also covers Pitfall 2 (AsyncTask ordering isn't guaranteed; + // self-validate instead of trusting submission order). + if (!Self->IsClientCurrent(ws, RequestClientID)) return; + + for (const FString& Endpoint : ValidNames) + { if (Action == "subscribe") { - - if (!EndpointSubscribers.Contains(Endpoint)) { - EndpointSubscribers.Add(Endpoint, TSet*>()); + if (!Self->EndpointSubscribers.Contains(Endpoint)) + { + Self->EndpointSubscribers.Add(Endpoint, TSet*>()); } - if (!bHasRunningPushDataLoop) { - StartWebSocketPushDataLoop(); - } + if (!Self->bHasRunningPushDataLoop) + { + Self->StartWebSocketPushDataLoop(); + } - EndpointSubscribers[Endpoint].Add(ws); + Self->EndpointSubscribers[Endpoint].Add(ws); UE_LOG(LogHttpServer, Warning, TEXT("Client subscribed to endpoint: %s"), *Endpoint); } - else if (Action == "unsubscribe" && EndpointSubscribers.Contains(Endpoint)) + else if (Action == "unsubscribe" && Self->EndpointSubscribers.Contains(Endpoint)) { - EndpointSubscribers[Endpoint].Remove(ws); + Self->EndpointSubscribers[Endpoint].Remove(ws); UE_LOG(LogHttpServer, Warning, TEXT("Client unsubscribed from endpoint: %s"), *Endpoint); } } - } - else if (JsonRequest->TryGetStringField(TEXT("endpoints"), Endpoint)) { + }); +} - if (Action == "subscribe") - { - if (!EndpointSubscribers.Contains(Endpoint)) { - EndpointSubscribers.Add(Endpoint, TSet*>()); - } +void AFicsitRemoteMonitoring::PushUpdatedData() { - if (!bHasRunningPushDataLoop) { - StartWebSocketPushDataLoop(); - } + // THRD-02 / Pitfall 3: PushUpdatedData runs on the push-pacing thread (Async(EAsyncExecution::Thread, + // ...) started in StartWebSocketPushDataLoop) — a third thread distinct from both the game thread and + // the uWS loop thread. Reading EndpointSubscribers here directly would be an unmarshaled read racing + // against the game thread's writes, and calling Client->send() here would violate uWS's single- + // thread socket affinity. So: (1) hop to the game thread to build each endpoint's JSON and snapshot + // its recipients as plain {ws, ClientID} pairs (never hand a live TSet/ws reference across threads), + // then (2) back on the pacing thread, defer() each send onto the loop thread, re-validating liveness + // against the loop-thread-only LoopLiveSockets set (D-03) before touching ws. + + struct FPushRecipient + { + uWS::WebSocket* Ws; + int32 ClientID; + }; - EndpointSubscribers[Endpoint].Add(ws); + struct FPushSnapshotEntry + { + FString Payload; + TArray Recipients; + }; - UE_LOG(LogHttpServer, Warning, TEXT("Client subscribed to endpoint: %s"), *Endpoint); - } - else if (Action == "unsubscribe") + TArray Snapshot; + + TWeakObjectPtr WeakThis(this); + FThreadSafeBool bSnapshotComplete = false; + + AsyncTask(ENamedThreads::GameThread, [WeakThis, &Snapshot, &bSnapshotComplete]() + { + AFicsitRemoteMonitoring* Self = WeakThis.Get(); + if (Self && !Self->bShouldStop) { - EndpointSubscribers[Endpoint].Remove(ws); - UE_LOG(LogHttpServer, Warning, TEXT("Client unsubscribed from endpoint: %s"), *Endpoint); - } - } -} + for (auto& Elem : Self->EndpointSubscribers) + { + if (Elem.Value.Num() == 0) + { + continue; + } -void AFicsitRemoteMonitoring::PushUpdatedData() { + bool bSuccess = false; + int32 ErrorCode = 404; - for (auto& Elem : EndpointSubscribers) { - FString Endpoint = Elem.Key; - - if (Elem.Value.Num() == 0) { - continue; + FRequestData RequestData = FRequestData(); + RequestData.bIsAuthorized = true; + + FString Json; + Self->HandleEndpoint(Elem.Key, RequestData, bSuccess, ErrorCode, Json, EInterfaceType::Socket); + + FPushSnapshotEntry Entry; + Entry.Payload = MoveTemp(Json); + + for (uWS::WebSocket* Client : Elem.Value) + { + // Re-validate against the authoritative generation map (D-03) rather than trusting + // that everything in EndpointSubscribers is still current. + if (const int32* FoundClientID = Self->ClientGenerations.Find(Client)) + { + Entry.Recipients.Add({ Client, *FoundClientID }); + } + } + + if (Entry.Recipients.Num() > 0) + { + Snapshot.Add(MoveTemp(Entry)); + } + } } + bSnapshotComplete = true; + }); + + // The pacing thread's whole job is this periodic snapshot-then-send cycle, so it is expected to wait + // synchronously for the result (same fire-and-wait shape as CallEndpoint's existing game-thread hop, + // FicsitRemoteMonitoring.cpp CallEndpoint) — unlike the inbound WS callbacks (D-02), nothing here is + // blocking the uWS loop thread. + while (!bSnapshotComplete) + { + FPlatformProcess::Sleep(0.0001f); + } - bool bSuccess = false; - int32 ErrorCode = 404; + if (!CapturedLoop) + { + // Loop already torn down (shutdown race) — nothing safe to defer onto. + return; + } - FRequestData RequestData = FRequestData(); - RequestData.bIsAuthorized = true; + for (const FPushSnapshotEntry& Entry : Snapshot) + { + // Owning UTF-8 copy: the deferred callback may run well after this stack frame (and this + // function's FTCHARToUTF8 buffer) is gone, so a plain char* into a stack-scoped converter must + // never be captured — copy into an owning std::string instead. + FTCHARToUTF8 Converted(*Entry.Payload); + const std::string PayloadUtf8(Converted.Get(), Converted.Length()); - FString Json; + for (const FPushRecipient& Recipient : Entry.Recipients) + { + uWS::WebSocket* Ws = Recipient.Ws; + const int32 ClientID = Recipient.ClientID; - this->HandleEndpoint(Endpoint, RequestData, bSuccess, ErrorCode, Json, EInterfaceType::Socket); + CapturedLoop->defer([WeakThis, Ws, ClientID, PayloadUtf8]() + { + // Now executing on the loop thread. Re-validate ws against the loop-thread-only liveness + // set before dereferencing it — the client may have disconnected (and its ws* memory + // possibly reused) between the game-thread snapshot above and this callback running. + AFicsitRemoteMonitoring* Self = WeakThis.Get(); + if (!Self) + { + return; + } - FTCHARToUTF8 Converted(*Json); - const char* UWSOutput = Converted.Get(); - - // Broadcast updated data to all clients subscribed to this endpoint - for (uWS::WebSocket* Client : Elem.Value) { - Client->send(UWSOutput, uWS::OpCode::TEXT); + const int32* FoundClientID = Self->LoopLiveSockets.Find(Ws); + if (FoundClientID && *FoundClientID == ClientID) + { + Ws->send(PayloadUtf8, uWS::OpCode::TEXT); + } + }); } } } @@ -658,7 +896,12 @@ void AFicsitRemoteMonitoring::HandleGetRequest(uWS::HttpResponse* res, uW if (!FileLoaded) { UE_LOG(LogHttpServer, Error, TEXT("Failed to load file: %s"), *FilePath); - UFRM_RequestLibrary::SendErrorMessage(res, "500 Internal Server Error", "Failed to load file."); + if (Extension == "html" || Extension == "htm") { + UFRM_RequestLibrary::SendFallbackPage(res, DocsURL); + } + else { + UFRM_RequestLibrary::SendErrorMessage(res, "500 Internal Server Error", "Failed to load file."); + } } } @@ -733,6 +976,18 @@ void AFicsitRemoteMonitoring::HandleApiRequest(UObject* World, uWS::HttpResponse } } +bool AFicsitRemoteMonitoring::IsRegisteredEndpointName(const FString& InName) const +{ + for (const FAPIEndpoint& EndpointInfo : APIEndpoints) + { + if (EndpointInfo.APIName == InName) + { + return true; + } + } + return false; +} + void AFicsitRemoteMonitoring::InitAPIRegistry() { //Registering Endpoints: API Name, bRequireGameThread, FunctionPtr @@ -863,7 +1118,7 @@ void AFicsitRemoteMonitoring::InitTrainDerailNotification() { if (!WITH_EDITOR) {/* auto World = GetWorld(); - + // void OnCollided( AFGRailroadVehicle* ourVehicle, float ourVelocity, AFGRailroadVehicle* otherVehicle, float otherVelocity, bool shouldDerail ); SUBSCRIBE_METHOD_AFTER(AFGRailroadSubsystem::OnTrainsCollided, [this](AFGTrain* PriTrain, AFGTrain* SecTrain) { @@ -872,7 +1127,6 @@ if (!WITH_EDITOR) {/* } } - void AFicsitRemoteMonitoring::RegisterEndpoint(const FAPIEndpoint& Endpoint) { APIEndpoints.Add(Endpoint); @@ -982,6 +1236,106 @@ void AFicsitRemoteMonitoring::AddErrorJson(TArray>& JsonA JsonArray.Add(MakeShared(JsonObject)); } +// VALD-01: strict WS subscribe/unsubscribe envelope validator. Runs synchronously on the uWS loop +// thread (called from ProcessClientRequest's synchronous prefix, before the AsyncTask(GameThread, +// ...) hop) — reads only the immutable-after-BeginPlay APIEndpoints registry, never +// EndpointSubscribers (that map remains exclusively game-thread-owned; see Pitfall 4 — a +// registered-but-not-currently-subscribed name is valid input for "unsubscribe"). +// +// Uses TryGetStringField/TryGetArrayField + explicit EJson::String checks throughout (never +// GetStringField/AsString, which silently default/coerce instead of failing) so malformed input is +// rejected instead of silently swallowed. +bool AFicsitRemoteMonitoring::ValidateWSSubscriptionEnvelope(const TSharedPtr& JsonRequest, FString& OutAction, TArray& OutValidNames, FString& OutErrorMessage) const +{ + // D-04: strict `action` extraction — TryGetStringField fails closed on a missing or + // wrong-typed field instead of GetStringField's silent "" default. + FString RawAction; + if (!JsonRequest->TryGetStringField(TEXT("action"), RawAction) + || (RawAction != TEXT("subscribe") && RawAction != TEXT("unsubscribe"))) + { + OutErrorMessage = FString::Printf( + TEXT("Invalid or missing 'action' field (got '%s'); expected 'subscribe' or 'unsubscribe'."), + *RawAction); + return false; // whole-request reject — nothing to mutate + } + OutAction = RawAction; + + // D-04/D-05: strict `endpoints` extraction — array-of-strings or single string only. A + // non-string array entry is a per-entry problem (D-03 partial success), NOT a whole-request + // reject; a missing/wrong-typed top-level `endpoints` field IS a whole-request reject. + TArray RawNames; + TArray Problems; + + const TArray>* EndpointsArray; + FString SingleEndpoint; + + if (JsonRequest->TryGetArrayField(TEXT("endpoints"), EndpointsArray)) + { + for (int32 Index = 0; Index < EndpointsArray->Num(); ++Index) + { + const TSharedPtr& EndpointValue = (*EndpointsArray)[Index]; + if (!EndpointValue.IsValid() || EndpointValue->Type != EJson::String) + { + Problems.Add(FString::Printf(TEXT("endpoints[%d] is not a string"), Index)); + continue; + } + RawNames.Add(EndpointValue->AsString()); + } + } + else if (JsonRequest->TryGetStringField(TEXT("endpoints"), SingleEndpoint)) + { + RawNames.Add(SingleEndpoint); + } + else + { + // Covers both "missing"/"null" and "wrong top-level type" (e.g. endpoints: 5) — D-04/D-05. + OutErrorMessage = TEXT("'endpoints' field is required and must be a string or an array of strings."); + return false; // whole-request reject + } + + // D-01/Pattern 2: GET-only registry match. PushUpdatedData always builds a default FRequestData + // (Method == "GET", FRM_RequestData.h) and never overrides it, so a WS client can never actually + // receive push data for a non-GET name — accepting one here would just move today's + // silent-garbage-forever bug one step later. APIEndpoints is populated once in InitAPIRegistry() + // (BeginPlay, before StartWebSocketServer) and never mutated after, so it is safe to read + // cross-thread here without marshaling. + for (const FString& Name : RawNames) + { + const bool bIsRegisteredGet = APIEndpoints.ContainsByPredicate([&Name](const FAPIEndpoint& Endpoint) + { + return Endpoint.APIName == Name && Endpoint.Method == TEXT("GET"); + }); + + if (bIsRegisteredGet) + { + OutValidNames.AddUnique(Name); + } + else + { + Problems.Add(FString::Printf(TEXT("Unknown endpoint: %s"), *Name)); + } + } + + // D-03: partial success — combine every per-entry problem (type errors first, then unknown + // names, in array-index order) into ONE error frame, but still return true: the envelope shape + // itself (action/endpoints) was valid, so surviving OutValidNames still feed the mutation. + if (Problems.Num() > 0) + { + // Backstop (T-04-03): cap the reflected problem list so a maliciously large all-invalid + // `endpoints` array cannot drive an unbounded error frame. + constexpr int32 MaxReportedProblems = 20; + if (Problems.Num() > MaxReportedProblems) + { + const int32 Remainder = Problems.Num() - MaxReportedProblems; + Problems.SetNum(MaxReportedProblems); + Problems.Add(FString::Printf(TEXT("...and %d more"), Remainder)); + } + OutErrorMessage = FString::Join(Problems, TEXT("; ")); + } + + return true; +} + void AFicsitRemoteMonitoring::HandleEndpoint(FString InEndpoint, FRequestData RequestData, bool& bSuccess, int32& ErrorCode, FString& Out_Data, EInterfaceType Interface) { bSuccess = false; diff --git a/Source/FicsitRemoteMonitoring/Public/FRM_Request.h b/Source/FicsitRemoteMonitoring/Public/FRM_Request.h index 4fc7b483..53b4cda4 100644 --- a/Source/FicsitRemoteMonitoring/Public/FRM_Request.h +++ b/Source/FicsitRemoteMonitoring/Public/FRM_Request.h @@ -22,6 +22,7 @@ class FICSITREMOTEMONITORING_API UFRM_RequestLibrary : public UFGBlueprintFuncti public: static void SendErrorJson(uWS::HttpResponse* res, const FString& Status, const FString& Json); static void SendErrorMessage(uWS::HttpResponse* res, const FString& Status, const FString& Message); + static void SendFallbackPage(uWS::HttpResponse* res, const FString& InDocsURL); static void AddResponseHeaders(uWS::HttpResponse* res, const bool bIncludeContentType); diff --git a/Source/FicsitRemoteMonitoring/Public/FicsitRemoteMonitoring.h b/Source/FicsitRemoteMonitoring/Public/FicsitRemoteMonitoring.h index dc86ff60..a97e224e 100644 --- a/Source/FicsitRemoteMonitoring/Public/FicsitRemoteMonitoring.h +++ b/Source/FicsitRemoteMonitoring/Public/FicsitRemoteMonitoring.h @@ -114,10 +114,22 @@ class FICSITREMOTEMONITORING_API AFicsitRemoteMonitoring : public AModSubsystem private: TFuture WebServer{}; - + bool bShouldStop = false; bool bHasRunningPushDataLoop = false; + // Loop-thread-only monotonic counter used to mint each connection's ClientID in wsBehavior.open. + int32 NextClientIDCounter{}; + + // Loop-thread-only liveness bookkeeping (ws -> ClientID). Written exclusively from wsBehavior.open/close + // and from inside Loop::defer() callbacks; never touched from the game thread. Consumed by Plan 03's + // deferred outbound sends. + TMap*, int32> LoopLiveSockets{}; + + // Captured once, on the uWS loop thread, inside StartWebSocketServer. Consumed by Plan 03's outbound + // Loop::defer() calls. + uWS::Loop* CapturedLoop{ nullptr }; + FString AuthenticationToken{}; friend class UFGPowerCircuitGroup; @@ -141,6 +153,12 @@ class FICSITREMOTEMONITORING_API AFicsitRemoteMonitoring : public AModSubsystem FCallEndpointResponse CallEndpoint(UObject* WorldContext, FString InEndpoint, FRequestData RequestData, bool& bSuccess, int32& ErrorCode); + /** True if InName exactly matches a registered API endpoint name (any method). Used by the + * catch-all GET handler to tell bare-form API requests (which are extensionless, e.g. + * "getWorldInv") apart from web UI page navigations before serving the missing-web-UI + * fallback page. */ + bool IsRegisteredEndpointName(const FString& InName) const; + UFUNCTION(BlueprintImplementableEvent, Category = "Ficsit Remote Monitoring") void GetDropPodInfo_BIE(const AFGDropPod* Droppod, TSubclassOf& ItemClass, int32& Amount, float& Power); @@ -172,6 +190,19 @@ class FICSITREMOTEMONITORING_API AFicsitRemoteMonitoring : public AModSubsystem TSet*> ConnectedClients{}; + // Game-thread-owned authoritative (ws -> ClientID) liveness/generation map. A marshaled task or deferred + // send must validate its (ws, ClientID) pair against this map (via IsClientCurrent) before touching + // ConnectedClients/EndpointSubscribers or dereferencing ws — defends against ABA/pointer-reuse (D-03). + TMap*, int32> ClientGenerations{}; + + // Player display-name cache, keyed by the stable online id from + // APlayerState::GetUniqueId().ToString() (consistent across reconnect). Populated from + // the getPlayer live loop via the crash-safe engine accessor (FUniqueNetIdRepl::IsValid + // + ToString null-check internally) — NOT FactoryGame's AFGPlayerState::GetUserID(), + // which asserts/SIGSEGVs on a dedicated server when the net id is unset. Never evicted + // (cleared only on server restart). + TMap PlayerNameCache{}; + UFUNCTION(BlueprintImplementableEvent, Category = "Ficsit Remote Monitoring") void InitSerialDevice(); @@ -190,16 +221,31 @@ class FICSITREMOTEMONITORING_API AFicsitRemoteMonitoring : public AModSubsystem UFUNCTION(BlueprintCallable, Category = "Ficsit Remote Monitoring") FArduinoConfig GetSerialConfig(); - void OnClientDisconnected(uWS::WebSocket* ws, int code, std::string_view message); void OnMessageReceived(uWS::WebSocket* ws, std::string_view message, uWS::OpCode opCode); void ProcessClientRequest(uWS::WebSocket* ws, const TSharedPtr& JsonRequest); + /** Game-thread ws-validity helper: true only if ws is still registered in ClientGenerations with a + * matching ClientID. ws is used only as an opaque map key here — never dereferenced. */ + bool IsClientCurrent(uWS::WebSocket* ws, int32 ClientID) const; + void PushUpdatedData(); void HandleGetRequest(uWS::HttpResponse* res, uWS::HttpRequest* req, FString FilePath); bool IsAuthorizedRequest(uWS::HttpRequest* req, FString RequiredToken); void AddResponseHeaders(uWS::HttpResponse* res, bool bIncludeContentType); void AddErrorJson(TArray>& JsonArray, const FString& ErrorMessage); + + /** Loop-thread-only, synchronous WS subscribe/unsubscribe envelope validator (VALD-01). Strictly + * validates `action` (must be "subscribe"/"unsubscribe") and `endpoints` (string or array of + * strings, GET-only registry match) using the TryGet-family plus explicit EJson::String checks + * -- never the silently coercing GetStringField/AsString accessors. Reads only the + * immutable-after-BeginPlay APIEndpoints registry; never touches EndpointSubscribers (that + * stays game-thread-only). Returns false only on a whole-request reject (invalid/missing + * action, missing/wrong-typed endpoints field) -- OutErrorMessage is empty only when the + * envelope was fully valid with no invalid entries. OutValidNames always carries every entry + * that passed both the string-type and registered-GET checks, even when OutErrorMessage is + * non-empty (D-03 partial success). */ + bool ValidateWSSubscriptionEnvelope(const TSharedPtr& JsonRequest, FString& OutAction, TArray& OutValidNames, FString& OutErrorMessage) const; TArray Flavor_Battery{}; TArray Flavor_Doggo{}; diff --git a/build/RUNBOOK.md b/build/RUNBOOK.md new file mode 100644 index 00000000..a2d9118c --- /dev/null +++ b/build/RUNBOOK.md @@ -0,0 +1,516 @@ +# Build Runbook + +Reproducible steps for compiling and packaging the FicsitRemoteMonitoring plugin +against the Satisfactory Mod Loader (SML) / Unreal Engine 5 toolchain on this +dev machine. This runbook explains each step; `build/package.sh` re-runs the +compile/package commands directly. + +This runbook documents the full pipeline end-to-end: **symlink → verify patch → +compile → package → deploy → smoke-test**, plus a Known pitfalls section. Every +step below has been run and verified live on this machine. + +## Local environment facts (this machine) + +- Unreal Engine (Satisfactory CSS fork): `/mnt/data/UE` +- SML host project (Starter Project): `/home/fabrice/dev/SatisfactoryModLoader` +- `wwise-cli`: on `PATH`; Wwise SDK already downloaded and integrated into the + host project's Wwise project (`SatisfactoryModLoader_WwiseProject`). This is + a one-time, already-completed provisioning step — it is **not** part of the + repeatable compile/package sequence below and `build/package.sh` never + invokes it. +- `wine` + `msvc-wine`: already configured (`UE_WINE_MSVC` exported in + `~/.bashrc`, pointing at `/opt/msvc-wine`). Provides the MSVC toolchain UBT + needs to produce real Win64 binaries when cross-compiling the client from + Linux. + +## Step 1 — Symlink the plugin into the host project + +SML mods are developed as standalone git repos symlinked (never copied — this +must stay a live dev loop against this repo's working tree) into the host +Starter Project's `Mods/GameFeatures//` directory. It **must** be +directly under `Mods/GameFeatures/` (not plain `Mods/`, not +`Mods/GameFeatures/GameFeatures/`) — Alpakit's packaging automation +(`IsGameFeatureDLC()`) keys off this exact path to decide how to stage the mod +and mark it as a Game Feature; getting the location wrong produces a mod that +either isn't recognized or mounts its content at the wrong runtime path. + +```bash +ln -s /home/fabrice/dev/FicsitRemoteMonitoring \ + /home/fabrice/dev/SatisfactoryModLoader/Mods/GameFeatures/FicsitRemoteMonitoring +``` + +Verify it resolves correctly: + +```bash +readlink -f /home/fabrice/dev/SatisfactoryModLoader/Mods/GameFeatures/FicsitRemoteMonitoring +# expected: /home/fabrice/dev/FicsitRemoteMonitoring +``` + +## Step 2 — Verify the SML header patch is applicable + +`Source/FicsitRemoteMonitoringServer/FicsitRemoteMonitoringServer.build.cs` +auto-applies `Patches/FGServerAPIManager-FRM-04162025.patch` (flips a +`private:` to `public:` before `friend class UFGServerAPIManager;` at line +~116 of the host project's `FGServerAPIManager.h`, so the server module can +access `FFGRequestData`). It does this by shelling out to `patch -N -s` at UBT +module-rules construction time, **before** the first compile. + +**Caveat — silent-fail path:** the patch-apply logic catches failures and only +logs to stdout; it does not fail the UBT invocation. A missing/broken `patch` +binary, or an already-diverged header, would otherwise surface later as a +confusing C++ compile error instead of a clear "patch failed" message. +De-risk this before the first compile with a dry-run: + +```bash +patch -N -s --dry-run \ + /home/fabrice/dev/SatisfactoryModLoader/Source/FactoryDedicatedServer/Public/Networking/FGServerAPIManager.h \ + /home/fabrice/dev/FicsitRemoteMonitoring/Patches/FGServerAPIManager-FRM-04162025.patch +``` + +Exit code `0` means the patch can apply cleanly (or is already applied). Do +not apply it manually — the build.cs applies it automatically on the first +`RunUBT.sh` invocation in Step 3. After that first compile, sanity-check it +actually took: + +```bash +sed -n '116p' /home/fabrice/dev/SatisfactoryModLoader/Source/FactoryDedicatedServer/Public/Networking/FGServerAPIManager.h +# expected: public: (was private: before the patch applied) +``` + +## Step 3 — Compile + +Compiles the `FactoryEditor` target for Linux, which builds both plugin +modules (`FicsitRemoteMonitoring` runtime + `FicsitRemoteMonitoringServer` +server-only) and triggers the patch from Step 2. + +```bash +bash build/package.sh compile +``` + +Equivalent to running directly: + +```bash +UE_DIR=/mnt/data/UE +SML_PROJECT=/home/fabrice/dev/SatisfactoryModLoader/FactoryGame.uproject + +"$UE_DIR/Engine/Build/BatchFiles/RunUBT.sh" FactoryEditor Linux Development -project="$SML_PROJECT" +``` + +Override `UE_DIR` / `SML_PROJECT` environment variables if your paths differ +from this machine's defaults. + +Confirm both modules were reported as built in the RunUBT.sh output, and +re-run the Step 2 sanity check to confirm the patch applied. + +## Step 4 — Package + +```bash +bash build/package.sh package +``` + +Equivalent to running directly: + +```bash +"$UE_DIR/Engine/Build/BatchFiles/RunUAT.sh" \ + -ScriptsForProject="$SML_PROJECT" \ + PackagePlugin \ + -project="$SML_PROJECT" \ + -DLCName=FicsitRemoteMonitoring \ + -build \ + -clientconfig=Shipping -serverconfig=Shipping \ + -platform=Win64 \ + -server -serverplatform=Linux \ + -nocompileeditor \ + -utf8output +``` + +Produces both required artifacts under +`/Saved/ArchivedPlugins/FicsitRemoteMonitoring/`: +`FicsitRemoteMonitoring-Windows.zip` (client) and +`FicsitRemoteMonitoring-LinuxServer.zip` (server). The Win64 zip's +`Binaries/Win64/` already contains `uv.dll` and `zlib1.dll` via the +`RuntimeDependencies` staging declared in the build.cs files — no manual DLL +copy step was needed on this machine. + +**Required external dependency — ArduinoKit:** this repo's +`Content/Subsystems/FicsitRemoteMonitoring_BP.uasset` hard-references +Blueprint nodes (serial/RS232 I/O) from the `ArduinoKit` plugin. Without it, +the cook stage fails outright (`ExitCode=25`, `Error_UnknownCookFailure`, +log mentions `/Script/ArduinoKit`). Per `CONTRIBUTING.md`, clone it into the +host project's `Mods/` folder (note: `Mods/`, not `Mods/GameFeatures/`): + +```bash +git clone https://github.com/porisius/ArduinoKit \ + /home/fabrice/dev/SatisfactoryModLoader/Mods/ArduinoKit +``` + +Then **re-run Step 3 (compile)** before packaging again — `ArduinoKit`'s own +C++ module is only source after cloning; `-nocompileeditor` in the package +stage means the newly-added module must already be built, or the cook fails +a second time with `Plugin 'ArduinoKit' failed to load because module +'ArduinoKit' could not be found`. This machine did not need `DiscIt` (it did +not surface in the cook failure, unlike ArduinoKit); do not pre-emptively +clone it — only add it if a future cook run demonstrably references it. + +ArduinoKit's clone lives in the sibling `SatisfactoryModLoader` checkout, not +in this repo — it is a build-environment dependency of the *host project*, +not a code change to this plugin, so it is not (and should not be) tracked by +this repo's git. + +## Icon release workflow (PKG-01) + +Icon generation is **client-only** — a dedicated server has no UE textures +loaded, so `/frm icon` cannot run there. The real bundling mechanism is +`Config/PluginSettings.ini`'s `[StageSettings] +AdditionalNonUSFDirectories=Icons` +(already present in this repo); `Config/FilterPlugin.ini` is inert for this +pipeline (Alpakit's `PackagePlugin` extends `BuildCookRun`, which never reads +it — see the comment added to that file). Verification for PKG-01 is therefore +against the **packaged zip's contents**, not against which `.ini` was edited. + +1. On a **game client** (not the dedicated server), run the in-game chat + command: + + ``` + /frm icon + ``` + + This generates icon PNGs into that client's local `Icons/` folder. + +2. Copy the generated PNGs from the client's `Icons/` folder into this repo's + source `Icons/` folder, then package: + + ```bash + bash build/package.sh package + ``` + +3. Verify the icons actually landed in the packaged artifact — an empty + source `Icons/` folder still ships the (empty) directory harmlessly, so + check the real zip contents, not the `.ini` edited: + + ```bash + unzip -l Saved/ArchivedPlugins/FicsitRemoteMonitoring/FicsitRemoteMonitoring-LinuxServer.zip \ + | grep 'Icons/' + # expected: the populated PNG files under .../Icons/, not just the bare directory entry + ``` + +4. After deploying (Step 5) and starting the HTTP server (Step 6), confirm a + known icon renders live: + + ```bash + curl -s -o /dev/null -w '%{http_code}\n' \ + http://:/Icons/_C.png + # expected: 200 once Icons/ is populated and deployed (404 when absent) + ``` + +## Step 5 — Deploy + +Unzip the packaged `-LinuxServer.zip` artifact from +`Saved/ArchivedPlugins/FicsitRemoteMonitoring/` into the dedicated server's mod +tree. **Critical:** FRM must land under `Mods/GameFeatures/`, not plain `Mods/`: + +```bash +SRV=/mnt/data/satisfactory-server/SatisfactoryDedicatedServer +unzip -o \ + /home/fabrice/dev/SatisfactoryModLoader/Saved/ArchivedPlugins/FicsitRemoteMonitoring/FicsitRemoteMonitoring-LinuxServer.zip \ + -d "$SRV/FactoryGame/Mods/GameFeatures/FicsitRemoteMonitoring" +``` + +Resulting layout (the `.uplugin` and `Content/Paks/` land directly under this dir): + +``` +FactoryGame/Mods/GameFeatures/FicsitRemoteMonitoring/FicsitRemoteMonitoring.uplugin +FactoryGame/Mods/GameFeatures/FicsitRemoteMonitoring/Content/Paks/LinuxServer/FicsitRemoteMonitoringFactoryGame-LinuxServer.{pak,utoc,ucas} +``` + +**Why `Mods/GameFeatures/` and not `Mods/`** (this is a real, confirmed failure +mode — see `.planning/debug/resolved/frm-worldmodule-not-cooked.md`): FRM is a +Game Feature mod. Its pak is cooked with a baked-in mount point of +`../../../FactoryGame/Mods/GameFeatures/FicsitRemoteMonitoring/` (Step 1's symlink +location drives this — Alpakit bakes the plugin's `Mods/GameFeatures/` path +into the pak). If you deploy the plugin one level too shallow at +`Mods/FicsitRemoteMonitoring/`, the runtime plugin content root no longer matches +the pak's baked mount point, so the cooked packages — including +`Content/InitGameWorld` (FRM's `GameWorldModule` blueprint) — never resolve under +the `FicsitRemoteMonitoring` plugin. SML's root-module asset-registry scan then +misses FRM entirely: the log reads `Discovered 2 world modules` (SML + ArduinoKit +only), **no** `AFicsitRemoteMonitoring*` subsystem spawns, `Registered API +Endpoint` stays `0`, and `/frm` reports "Unknown command" — even though the pak +mounts cleanly and SML still logs `FicsitRemoteMonitoring: 1.5.2` (that version +line comes from the C++ Binaries, not the content pak, which is what masks the +failure). Contrast: ArduinoKit is a plain mod cooked with mount point +`Mods/ArduinoKit/`, so it deploys to `Mods/ArduinoKit/` and matches. + +Verify the deploy path before launching: + +```bash +test -f "$SRV/FactoryGame/Mods/GameFeatures/FicsitRemoteMonitoring/FicsitRemoteMonitoring.uplugin" \ + && echo "FRM deployed under GameFeatures (correct)" \ + || { echo "WRONG PATH — FRM not under Mods/GameFeatures/"; exit 1; } +``` + +Launch the server (output captured for the smoke test in Step 6): + +```bash +cd "$SRV" +./FactoryServer.sh -log -unattended > /mnt/data/satisfactory-server/server-run.log 2>&1 & +``` + +Confirm discovery succeeded once a session loads: + +```bash +grep 'Discovered .* world modules' /mnt/data/satisfactory-server/server-run.log | tail -1 +# expected: "Discovered 3 world modules of class GameWorldModule" (SML + ArduinoKit + FRM) +grep -c 'Registered API Endpoint' /mnt/data/satisfactory-server/server-run.log +# expected: > 0 (93 on this build) +``` + +## Step 6 — Smoke test + +FRM's uWS HTTP server is **off by default** on a dedicated server (`uWS.Autostart` +defaults to `false`) and binds `uWS.Port` (default `8080`). `uWS.Autostart=1` (or +running `/frm http start` in-game) is required before the server opens the port — +without it the port never binds and the curl below will fail to connect regardless +of whether the mod loaded correctly. + +The canonical smoke test, matching this phase's stated verification bar, is: + +```bash +curl -sf http://localhost:8080/api/getWorldInv +``` + +Expect HTTP 200 with a JSON body. Both `/getWorldInv` and `/api/getWorldInv` are +served by the uWS router; either path form works against the same port. + +**If port `8080` is already occupied** (e.g. a colocated Satisfactory game client +also running FRM on this machine, as on this dev box), set `uWS.Port` to a free +port before launching and curl that port instead. Settings live under the +`FicsitRemoteMonitoring.Server.` prefix in the server's `GameUserSettings.ini`; +booleans are stored in `mIntValues` as `0/1`: + +```ini +[/Script/FactoryGame.FGGameUserSettings] +mIntValues=(("FicsitRemoteMonitoring.Server.uWS.Port", 8091),("FicsitRemoteMonitoring.Server.uWS.Autostart", 1)) +``` + +After launch + session load, confirm the listener and query live data: + +```bash +ss -tlnp | grep ':8091' # expect a FactoryServer LISTEN +curl -s http://localhost:8091/getModList # expect JSON incl. "Ficsit Remote Monitoring" 1.5.2 +curl -s -o /dev/null -w 'HTTP %{http_code}\n' \ + http://localhost:8091/getWorldInv # expect HTTP 200 +``` + +A `200` with well-formed JSON confirms the monitoring API is live end-to-end on the +dedicated server. + +### PKG-02 fallback-page smoke test + +Verifies the branded 503 fallback page (`UFRM_RequestLibrary::SendFallbackPage`) +appears when the web UI's files are missing, while every other route keeps its +existing, unchanged behavior (missing sub-resources stay 404 JSON, `/Icons/*` +stays 404, `/api/*` stays JSON). This is a **manual, restorable** procedure — +temporarily rename the deployed `www/` folder aside, run the curl battery below, +then restore it. Use the same port as Step 6 (`8091` in these examples if you +overrode the default, or `8080` otherwise). + +1. Rename `www/` aside so the web UI files are missing: + + ```bash + mv "$SRV/FactoryGame/Mods/GameFeatures/FicsitRemoteMonitoring/www" \ + "$SRV/FactoryGame/Mods/GameFeatures/FicsitRemoteMonitoring/www.bak" + ``` + +2. Run the six-case curl battery: + + ```bash + # (1) page request with www/ missing -> expect 503, branded HTML body + curl -s -o /dev/null -w 'HTTP %{http_code}\n' \ + http://localhost:8091/index.html # expect HTTP 503 + curl -s http://localhost:8091/index.html | grep -q 'api' \ + && echo "fallback body OK (carries /api/ note)" + + # (2) missing sub-resource -> unchanged 404 JSON, NOT the HTML fallback + curl -s -o /dev/null -w 'HTTP %{http_code}\n' \ + http://localhost:8091/_next/static/nonexistent.js # expect HTTP 404 + curl -s http://localhost:8091/_next/static/nonexistent.js # expect a JSON body, not HTML + + # (3) /Icons/* stays on its documented, unmodified behavior + curl -s -o /dev/null -w 'HTTP %{http_code}\n' \ + http://localhost:8091/Icons/Nonexistent_C.png # expect HTTP 404 + + # (4) /api/* stays JSON, unaffected by the page fallback + curl -s http://localhost:8091/api/nonexistentEndpoint # expect a JSON body (unchanged, not HTML) + + # (5) REGRESSION GUARD: bare-form API endpoints are extensionless like a page + # request, but must NOT be diverted to the 503 fallback even while www/ is + # missing -- the catch-all consults the endpoint registry first. + curl -s -o /dev/null -w 'HTTP %{http_code}\n' \ + http://localhost:8091/getWorldInv # expect HTTP 200 (JSON, not the fallback) + curl -s http://localhost:8091/getModList # expect a JSON body, not the HTML fallback + ``` + +3. Restore `www/` and confirm normal service resumes: + + ```bash + mv "$SRV/FactoryGame/Mods/GameFeatures/FicsitRemoteMonitoring/www.bak" \ + "$SRV/FactoryGame/Mods/GameFeatures/FicsitRemoteMonitoring/www" + + curl -s -o /dev/null -w 'HTTP %{http_code}\n' \ + http://localhost:8091/index.html # expect HTTP 200 again + ``` + +**If port `8091` is not yours** (i.e. you did not override the default per Step +6's colocated-client note), substitute `8080` throughout the battery above. + +### Validating the Windows client package (`-Windows.zip`) + +The Linux server package is the primary target, but the Windows client build is +validated the same way — deploy it into a game **client** install and load a +**single-player** world (no dedicated server needed; the world is ready immediately). +On the client, `uWS.Autostart` is typically already enabled and the options live +under the `FicsitRemoteMonitoring.` prefix (no `Server.` segment). Deploy path is the +same `Mods/GameFeatures/FicsitRemoteMonitoring/`: + +```bash +# with the game client CLOSED, back up any existing install first, then: +unzip -o FicsitRemoteMonitoring-Windows.zip \ + -d "/FactoryGame/Mods/GameFeatures/FicsitRemoteMonitoring" +``` + +Launch the client, load a single-player world, then: + +```bash +curl -s -o /dev/null -w 'HTTP %{http_code}\n' http://localhost:8080/getWorldInv +``` + +Expect HTTP 200 (the body may be `[]` on a brand-new save with no inventory +containers — an empty JSON array is still a valid 200). Confirm in the client's +`FactoryGame.log` that the mod loaded with **no** module-load error (the vendored +`uv.dll`/`zlib1.dll` in the package's `Binaries/Win64/` mean the CONTRIBUTING.md +DLL-copy fallback is normally unnecessary) and that SML logged +`Discovered N world modules of class GameWorldModule` counting your FRM install. + +> Note: the alternate dedicated-server route — the game Server API on `:7777` via a +> custom `Frm` function (`UFRM_Controller::Handler_Frm`) — currently returns +> `bad_function` even after the FRM server subsystem spawns; the mod's +> `RegisterRequestHandler` does not surface in the `:7777` dispatch table. Use the +> uWS route above for smoke testing until that separate issue is resolved. + +## WebSocket stress harness (Phase 2 verification) + +`build/tools/ws-stress-harness.mjs` is a **throwaway verification artifact** +for Phase 2 (thread-safe-websocket-request-handling) — consistent with this +project's no-test-framework convention (manual/compile-time verification +per `.claude/CLAUDE.md`), it is **not** a committed permanent test and is +**not** wired into `build/package.sh`. Run it manually, by hand, when you +need to soak-test the WebSocket marshaling changes (THRD-01/THRD-02). + +It uses Node's built-in global `WebSocket` and `fetch` (Node v24+) — no +`npm install` required, no new dependency added to the repo. + +### Prerequisite + +Deploy a build and start the server with the uWS listener enabled +(`uWS.Autostart=1`), per Steps 3–6 above. Confirm the listener is up before +running the harness: + +```bash +ss -tlnp | grep ':8080' # or the configured uWS.Port, e.g. 8091 on this box +``` + +### Run command + +```bash +node build/tools/ws-stress-harness.mjs --help + +# Short smoke run: +node build/tools/ws-stress-harness.mjs --host 127.0.0.1 --port 8091 --clients 25 --duration 60 + +# Full sustained soak run (target ~5-10 min per D-01): +node build/tools/ws-stress-harness.mjs --host 127.0.0.1 --port 8091 --clients 25 --duration 600 +``` + +Flags: `--host` (default `127.0.0.1`), `--port` (default `8080`, matching +`uWS.Port`; use `8091` on this dev box where the dedicated server and the +game client are colocated — see Step 6's port note), `--clients` (default +`25`), `--duration` (seconds, default `60`), `--endpoint` (subscribe target, +default `getWorldInv`), `--probe-path` (liveness probe path, default derived +from `--endpoint`). + +Each virtual client loops connect → subscribe → unsubscribe → disconnect +against the uWS endpoint for the full run duration, then the harness closes +all sockets, waits a short settle period, and performs an HTTP liveness +probe (`GET /getWorldInv` by default). It prints a summary line with +connect/close/error counters and exits `0` only if every socket closed +cleanly **and** the probe returned HTTP 200. + +### Pass criterion + +A sustained run (~5–10 min target) passes when **both** of the following +hold: + +1. **No crash / no hang** — the harness itself exits `0` (liveness probe + stayed `200` throughout; no unexpected socket error pattern was + detected). +2. **No leak** — `ConnectedClients` / `EndpointSubscribers` counts return to + baseline after all harness clients disconnect. No live endpoint exposes + these counts directly, so read them from the server's existing + `LogHttpServer` connection-count log lines (the `open`/`close` handlers + already log `ConnectedClients.Num()` on every connect/disconnect): + + ```bash + tail -f /mnt/data/satisfactory-server/server-run.log | grep -E 'Connections|Remaining connections' + # expect "Remaining connections: 0" after each churn cycle settles, + # and no runaway growth in the "Connections: N" values while the + # harness is running + ``` + + A clean run shows `Remaining connections: 0` recurring after each + client's disconnect — confirmed live on this machine with a short smoke + run (3 clients / 5s / 99 churn cycles): the harness exited `0`, the + liveness probe returned HTTP 200, and `Remaining connections: 0` appeared + after every cycle in the server log, with no count drifting away from + baseline. + +This is a manual verification step (per `02-VALIDATION.md`'s Manual-Only +Verifications table) — no automated CI hook exists for it; run it before +`/gsd-verify-work` for this phase and whenever the marshaling code changes. + +## Known pitfalls + +- **Plugin not yet linked into the host project** — the very first build + attempt fails with "plugin not found" if Step 1 was skipped. +- **SML header patch silent-fail** — see Step 2's caveat; always dry-run + before trusting a green compile as proof the patch applied. +- **Missing `ArduinoKit` Blueprint dependency** — confirmed on this machine: + the cook stage fails outright without it (see Step 4). `DiscIt` did not + surface as a failure in this run; do not pre-emptively clone it. +- **`-nocompileeditor` before the editor has ever built this plugin (or a + newly-cloned dependency plugin like `ArduinoKit`)** — always run the + explicit compile (Step 3) after cloning `ArduinoKit` and before packaging + with `-nocompileeditor`, or the package step fails with `Plugin + 'ArduinoKit' failed to load because module 'ArduinoKit' could not be + found` — confirmed on this machine. +- **Disk space** — the root filesystem (`/`) on this machine is at ~95% + capacity with a limited margin free; UE's cook → stage → archive pipeline + creates multiple intermediate copies per platform. Monitor free space + during packaging; if it becomes a blocker, this is a pre-existing + environment constraint outside this phase's scope to fix. +- **DLL copy for the Windows client** — per `CONTRIBUTING.md`'s troubleshooting + section, if the packaged Win64 client fails to load with a module-load + error, copy `zlib.dll` and `uv.dll` from `Source/ThirdParty/uWebSockets/lib` + to the packaged mod's `Binaries/Win64` directory. Try without this manual + copy first — `RuntimeDependencies` staging declared in the build.cs files + should already handle it. +- **Deployed one level too shallow (`Mods/FicsitRemoteMonitoring/` instead of + `Mods/GameFeatures/FicsitRemoteMonitoring/`)** — confirmed failure mode on + this machine, see Step 5. FRM's pak is cooked with a baked-in mount point + under `Mods/GameFeatures/`; deploying to plain `Mods/` deceptively looks + fine (SML still logs `FicsitRemoteMonitoring: 1.5.2` from the C++ Binaries, + and the pak still mounts) but the content pak's `GameWorldModule` never + resolves, so SML's log reads `Discovered 2 world modules` instead of `3`, + `Registered API Endpoint` stays at `0`, and `/frm` reports "Unknown + command". Always verify with the Step 5 `test -f ... .uplugin` deploy-path + guard and the `Discovered 3 world modules` / endpoint-count check before + moving on to the smoke test. diff --git a/build/package.sh b/build/package.sh new file mode 100755 index 00000000..1fd38790 --- /dev/null +++ b/build/package.sh @@ -0,0 +1,75 @@ +set -euo pipefail + +# build/package.sh — FicsitRemoteMonitoring build/package pipeline +# +# Reproducible wrapper around this project's verified UBT/UAT invocations +# (see build/RUNBOOK.md and .planning/phases/01-build-environment-configuration/01-RESEARCH.md). +# +# IMPORTANT: run this script with an explicit bash interpreter, e.g.: +# bash build/package.sh compile +# (there is intentionally no shebang line — invoking it directly via +# `./build/package.sh` on a system where /bin/sh is not bash, e.g. Ubuntu's +# dash, will fail because `set -o pipefail` is a bash-only option). +# +# Stages (first positional argument; defaults to "compile"): +# compile Compiles FactoryEditor (Linux, Development). Builds both plugin +# modules (FicsitRemoteMonitoring + FicsitRemoteMonitoringServer) +# and triggers the SML header patch auto-apply. This is the only +# stage implemented so far — see build/RUNBOOK.md for status. +# package Not yet implemented (added in a later plan of this phase — +# packages both the Win64 client and Linux server via RunUAT.sh +# PackagePlugin). +# +# Environment variables (override the this-machine defaults below): +# UE_DIR Path to the Unreal Engine (Satisfactory CSS fork) checkout. +# Default: /mnt/data/UE +# SML_PROJECT Path to the SatisfactoryModLoader host project's .uproject. +# Default: /home/fabrice/dev/SatisfactoryModLoader/FactoryGame.uproject +# +# No secrets are embedded or logged by this script. It only invokes local, +# fixed-path engine tooling already installed on the build machine. + +UE_DIR="${UE_DIR:-/mnt/data/UE}" +SML_PROJECT="${SML_PROJECT:-/home/fabrice/dev/SatisfactoryModLoader/FactoryGame.uproject}" + +STAGE="${1:-compile}" + +stage_compile() { + echo "==> [compile] RunUBT.sh FactoryEditor Linux Development -project=${SML_PROJECT}" + "${UE_DIR}/Engine/Build/BatchFiles/RunUBT.sh" FactoryEditor Linux Development -project="${SML_PROJECT}" +} + +stage_package() { + echo "==> [package] RunUAT.sh PackagePlugin -DLCName=FicsitRemoteMonitoring -platform=Win64 -server -serverplatform=Linux" + # D-01/D-02 scope: Win64 client + Linux server only. Deliberately OMITS + # -serverplatform=Win64+Linux (no WindowsServer target), -installed (this + # is a source-built engine, not an Epic-installed one), and -merge (keep + # separate per-platform zips). Shipping config for both — the only + # supported/tested configuration per Alpakit's own default. Relies on + # -nocompileeditor since the compile stage above already built the + # editor with both plugin modules linked in. + "${UE_DIR}/Engine/Build/BatchFiles/RunUAT.sh" \ + -ScriptsForProject="${SML_PROJECT}" \ + PackagePlugin \ + -project="${SML_PROJECT}" \ + -DLCName=FicsitRemoteMonitoring \ + -build \ + -clientconfig=Shipping -serverconfig=Shipping \ + -platform=Win64 \ + -server -serverplatform=Linux \ + -nocompileeditor \ + -utf8output +} + +case "${STAGE}" in + compile) + stage_compile + ;; + package) + stage_package + ;; + *) + echo "Unknown stage: ${STAGE} (expected: compile|package)" >&2 + exit 1 + ;; +esac diff --git a/build/tools/ws-stress-harness.mjs b/build/tools/ws-stress-harness.mjs new file mode 100644 index 00000000..ca93b668 --- /dev/null +++ b/build/tools/ws-stress-harness.mjs @@ -0,0 +1,357 @@ +#!/usr/bin/env node +/** + * ws-stress-harness.mjs + * + * Throwaway D-01 verification instrument for Phase 2 (thread-safe-websocket-request-handling). + * Not shipped plugin code, not wired into build/package.sh — run manually per build/RUNBOOK.md. + * + * Drives N concurrent virtual WebSocket clients through a tight + * connect -> subscribe -> unsubscribe -> disconnect churn loop against FRM's + * live uWS endpoint, then performs an HTTP liveness probe. A server crash or + * hang under the churn turns into a non-zero process exit code, so this can + * be used as an automated pass/fail signal (see build/RUNBOOK.md "WebSocket + * stress harness" section for the full pass criterion, including the + * subscriber/client-count baseline check read from server logs). + * + * Zero new dependencies: uses Node's built-in global `WebSocket` (available + * since Node 21+, confirmed present on this machine's Node v24.15.0 via + * `node -e "console.log(typeof WebSocket)"`) and the built-in global `fetch`. + * Do NOT `npm install ws` — the npm `ws` package was audited and REJECTED + * for this harness (see .planning/phases/02-thread-safe-websocket-request-handling/02-RESEARCH.md, + * "Package Legitimacy Audit"). + * + * Usage: + * node build/tools/ws-stress-harness.mjs --help + * node build/tools/ws-stress-harness.mjs --clients 25 --duration 60 + * node build/tools/ws-stress-harness.mjs --host 127.0.0.1 --port 8091 --clients 50 --duration 300 + */ + +'use strict'; + +const DEFAULTS = { + host: '127.0.0.1', + port: 8080, + clients: 25, + duration: 60, // seconds + endpoint: 'getWorldInv', + probePath: null, // derived from --endpoint if not set + holdMs: 250, // time a client stays subscribed before unsubscribing + reconnectDelayMs: 50, // brief pause between disconnect and reconnect + openTimeoutMs: 5000, + probeTimeoutMs: 5000, + settleMs: 2000, // wait after closing all sockets before probing +}; + +function printUsage() { + const lines = [ + 'ws-stress-harness.mjs — Phase 2 WebSocket stress/churn verification harness', + '', + 'Opens N concurrent WebSocket clients against FicsitRemoteMonitoring\'s uWS', + 'endpoint and loops each one through connect -> subscribe -> unsubscribe ->', + 'disconnect for --duration seconds. After the run, performs an HTTP liveness', + 'probe; exits non-zero if the server did not answer (crash/hang signal).', + '', + 'Usage:', + ' node build/tools/ws-stress-harness.mjs [options]', + '', + 'Options:', + ` --host Server host (default: ${DEFAULTS.host})`, + ` --port uWS port (default: ${DEFAULTS.port}; dedicated server default is 8080, this box currently runs 8091)`, + ` --clients Concurrent virtual clients (default: ${DEFAULTS.clients})`, + ` --duration Total churn run duration in seconds (default: ${DEFAULTS.duration})`, + ` --endpoint Endpoint name to subscribe/unsubscribe (default: ${DEFAULTS.endpoint})`, + ' --probe-path HTTP liveness probe path (default: derived from --endpoint, e.g. /getWorldInv)', + ` --hold-ms Time a client stays subscribed before unsubscribing (default: ${DEFAULTS.holdMs})`, + ' --help Print this usage and exit 0', + '', + 'Exit codes:', + ' 0 All sockets closed cleanly AND the liveness probe returned HTTP 200.', + ' 1 Liveness probe failed or timed out (server crashed or hung).', + ' 2 Unexpected socket error pattern occurred during the churn run.', + '', + 'Example:', + ' node build/tools/ws-stress-harness.mjs --host 127.0.0.1 --port 8091 --clients 25 --duration 60', + ]; + console.log(lines.join('\n')); +} + +function parseArgs(argv) { + const opts = { ...DEFAULTS }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + switch (arg) { + case '--help': + case '-h': + opts.help = true; + break; + case '--host': + opts.host = argv[++i]; + break; + case '--port': + opts.port = parseInt(argv[++i], 10); + break; + case '--clients': + opts.clients = parseInt(argv[++i], 10); + break; + case '--duration': + opts.duration = parseInt(argv[++i], 10); + break; + case '--endpoint': + opts.endpoint = argv[++i]; + break; + case '--probe-path': + opts.probePath = argv[++i]; + break; + case '--hold-ms': + opts.holdMs = parseInt(argv[++i], 10); + break; + default: + console.error(`Unknown argument: ${arg} (see --help)`); + process.exit(2); + } + } + if (!opts.probePath) { + opts.probePath = `/${opts.endpoint}`; + } + return opts; +} + +// --- Shared run-wide counters ------------------------------------------------- + +function makeCounters() { + return { + opens: 0, + cleanCloses: 0, + unexpectedCloses: 0, + subscribesSent: 0, + unsubscribesSent: 0, + messagesReceived: 0, + connectErrors: 0, + otherErrors: 0, + }; +} + +/** + * Runs a single virtual client's connect -> subscribe -> unsubscribe -> + * disconnect churn loop repeatedly until `isRunning()` returns false. + */ +async function runClientLoop(clientId, opts, counters, isRunning, activeSockets) { + const url = `ws://${opts.host}:${opts.port}/`; + + while (isRunning()) { + let ws; + try { + ws = await openSocket(url, opts.openTimeoutMs); + } catch (err) { + counters.connectErrors++; + await sleep(opts.reconnectDelayMs); + continue; + } + + activeSockets.add(ws); + counters.opens++; + + ws.addEventListener('message', () => { + counters.messagesReceived++; + }); + + let unexpectedClose = false; + const closePromise = new Promise((resolve) => { + ws.addEventListener('close', (evt) => { + activeSockets.delete(ws); + // 1000 (normal) and 1005 (no status, common on a deliberate close()) + // are both treated as clean for this harness's purposes. + if (evt.code !== 1000 && evt.code !== 1005) { + unexpectedClose = true; + } + resolve(); + }); + ws.addEventListener('error', () => { + unexpectedClose = true; + }); + }); + + try { + // Subscribe frame — matches ProcessClientRequest's expected shape: + // { "action": "subscribe", "endpoints": [...] } + ws.send(JSON.stringify({ action: 'subscribe', endpoints: [opts.endpoint] })); + counters.subscribesSent++; + + await sleep(opts.holdMs); + + if (!isRunning()) { + // Duration elapsed while holding subscribed — still unsubscribe/close + // cleanly rather than abandoning the socket mid-loop. + } + + ws.send(JSON.stringify({ action: 'unsubscribe', endpoints: [opts.endpoint] })); + counters.unsubscribesSent++; + } catch (err) { + counters.otherErrors++; + } + + try { + ws.close(1000, 'churn-cycle-complete'); + } catch (err) { + counters.otherErrors++; + } + + await closePromise; + if (unexpectedClose) { + counters.unexpectedCloses++; + } else { + counters.cleanCloses++; + } + + await sleep(opts.reconnectDelayMs); + } +} + +function openSocket(url, timeoutMs) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url); + const timer = setTimeout(() => { + try { + ws.close(); + } catch (_) { + /* ignore */ + } + reject(new Error(`open timed out after ${timeoutMs}ms`)); + }, timeoutMs); + + ws.addEventListener('open', () => { + clearTimeout(timer); + resolve(ws); + }); + ws.addEventListener('error', (err) => { + clearTimeout(timer); + reject(err); + }); + }); +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * HTTP liveness probe using Node's built-in fetch. Returns true only on a + * 200 response within the timeout; false (never throws) otherwise so the + * caller can turn a crashed/hung server into a clear non-zero exit. + */ +async function livenessProbe(opts) { + const url = `http://${opts.host}:${opts.port}${opts.probePath}`; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), opts.probeTimeoutMs); + try { + const res = await fetch(url, { signal: controller.signal }); + clearTimeout(timer); + return { ok: res.status === 200, status: res.status, url }; + } catch (err) { + clearTimeout(timer); + return { ok: false, status: null, url, error: err.message || String(err) }; + } +} + +async function main() { + const opts = parseArgs(process.argv.slice(2)); + + if (opts.help) { + printUsage(); + process.exit(0); + } + + if (!Number.isInteger(opts.clients) || opts.clients <= 0) { + console.error('--clients must be a positive integer'); + process.exit(2); + } + if (!Number.isInteger(opts.duration) || opts.duration <= 0) { + console.error('--duration must be a positive integer (seconds)'); + process.exit(2); + } + if (typeof WebSocket === 'undefined') { + console.error( + 'Global WebSocket is not available in this Node runtime. This harness ' + + 'requires Node 21+ (built-in WebSocket global). Detected: ' + process.version + ); + process.exit(2); + } + + const counters = makeCounters(); + const activeSockets = new Set(); + const startTime = Date.now(); + const endTime = startTime + opts.duration * 1000; + const isRunning = () => Date.now() < endTime; + + console.log( + `[ws-stress-harness] starting: host=${opts.host} port=${opts.port} ` + + `clients=${opts.clients} duration=${opts.duration}s endpoint=${opts.endpoint} ` + + `probe=${opts.probePath}` + ); + + const clientPromises = []; + for (let i = 0; i < opts.clients; i++) { + clientPromises.push(runClientLoop(i, opts, counters, isRunning, activeSockets)); + } + + await Promise.all(clientPromises); + + // Force-close any sockets still open past the duration window (defensive; + // the per-client loop already closes cleanly on each cycle boundary). + for (const ws of activeSockets) { + try { + ws.close(1000, 'harness-shutdown'); + } catch (_) { + /* ignore */ + } + } + + await sleep(opts.settleMs); + + const probeResult = await livenessProbe(opts); + + const elapsedSec = ((Date.now() - startTime) / 1000).toFixed(1); + console.log('[ws-stress-harness] run summary:'); + console.log(` elapsed: ${elapsedSec}s`); + console.log(` opens: ${counters.opens}`); + console.log(` clean closes: ${counters.cleanCloses}`); + console.log(` unexpected closes: ${counters.unexpectedCloses}`); + console.log(` subscribes sent: ${counters.subscribesSent}`); + console.log(` unsubscribes sent: ${counters.unsubscribesSent}`); + console.log(` messages received: ${counters.messagesReceived}`); + console.log(` connect errors: ${counters.connectErrors}`); + console.log(` other errors: ${counters.otherErrors}`); + console.log( + ` liveness probe: ${probeResult.url} -> ${ + probeResult.status !== null ? `HTTP ${probeResult.status}` : `FAILED (${probeResult.error})` + }` + ); + + if (!probeResult.ok) { + console.error( + '[ws-stress-harness] FAIL: liveness probe did not return HTTP 200 — server ' + + 'may have crashed or hung under load.' + ); + process.exit(1); + } + + const hadUnexpectedSocketErrors = + counters.unexpectedCloses > 0 || counters.otherErrors > 0 || counters.connectErrors > 0; + + if (hadUnexpectedSocketErrors) { + console.error( + '[ws-stress-harness] FAIL: unexpected socket error pattern occurred during the churn run ' + + `(unexpectedCloses=${counters.unexpectedCloses}, otherErrors=${counters.otherErrors}, ` + + `connectErrors=${counters.connectErrors}).` + ); + process.exit(2); + } + + console.log('[ws-stress-harness] PASS: no crash, liveness probe returned HTTP 200, all sockets closed cleanly.'); + process.exit(0); +} + +main().catch((err) => { + console.error('[ws-stress-harness] fatal error:', err); + process.exit(2); +}); diff --git a/build/tools/ws-validation-probe.mjs b/build/tools/ws-validation-probe.mjs new file mode 100644 index 00000000..aad52b78 --- /dev/null +++ b/build/tools/ws-validation-probe.mjs @@ -0,0 +1,461 @@ +#!/usr/bin/env node +/** + * ws-validation-probe.mjs + * + * Wave-0 VALD-01 verification instrument for Phase 4 (request-validation-hardening). + * Not shipped plugin code, not wired into build/package.sh — run manually per + * build/RUNBOOK.md. This is the failing end-to-end test that exists BEFORE the + * C++ validator (Plan 02): it defines the concrete acceptance target, and is + * the instrument the live-verification checkpoint (Plan 03) runs against a + * deployed dedicated server. + * + * Drives a battery of malformed and well-formed WebSocket subscribe/unsubscribe + * payloads against FRM's live uWS endpoint and asserts each produces the + * expected `{"error": ...}` frame (or, for the well-formed regression case, + * the expected push data with NO error frame). See 04-VALIDATION.md's + * "Per-Task Verification Map" (SC#1a/SC#1b/SC#2/SC#3) and 04-RESEARCH.md's + * "Phase Requirements -> Test Map" for the exact payloads and expected + * outcomes this script encodes. + * + * Zero new dependencies: uses Node's built-in global `WebSocket` (available + * since Node 21+) and the built-in global `fetch`. Do NOT `npm install ws` — + * the npm `ws` package was audited and REJECTED for this project's probes + * (see .planning/phases/02-thread-safe-websocket-request-handling/02-RESEARCH.md, + * "Package Legitimacy Audit"; reaffirmed for Phase 4 in 04-RESEARCH.md). + * + * Usage: + * node build/tools/ws-validation-probe.mjs --help + * node build/tools/ws-validation-probe.mjs + * node build/tools/ws-validation-probe.mjs --host 127.0.0.1 --port 8091 + * + * Exit codes: + * 0 All cases behaved as expected (malformed cases produced error frames, + * well-formed regression case succeeded). + * 1 A validation case regressed (a malformed case produced no error frame, + * or the well-formed case failed). + * 2 Connection/protocol error (could not reach the server, or an + * unexpected exception occurred during the run). + */ + +'use strict'; + +const DEFAULTS = { + host: '127.0.0.1', + port: 8080, + timeoutMs: 3000, // per-case receive timeout + openTimeoutMs: 5000, +}; + +function printUsage() { + const lines = [ + 'ws-validation-probe.mjs — Phase 4 VALD-01 malformed-payload WS validation probe', + '', + 'Opens WebSocket connections against FicsitRemoteMonitoring\'s uWS endpoint and', + 'sends a battery of malformed and well-formed subscribe/unsubscribe payloads,', + 'asserting each produces the expected {"error": ...} frame (or, for the', + 'well-formed regression case, the expected push data with no error frame).', + '', + 'Usage:', + ' node build/tools/ws-validation-probe.mjs [options]', + '', + 'Options:', + ` --host Server host (default: ${DEFAULTS.host})`, + ` --port uWS port (default: ${DEFAULTS.port}; dedicated server default is 8080)`, + ` --timeout-ms Per-case receive timeout in ms (default: ${DEFAULTS.timeoutMs})`, + ' --help Print this usage and exit 0', + '', + 'Exit codes:', + ' 0 All cases behaved as expected (malformed -> error frame, well-formed -> success).', + ' 1 A validation case regressed.', + ' 2 Connection/protocol error.', + '', + 'Example:', + ' node build/tools/ws-validation-probe.mjs --host 127.0.0.1 --port 8091', + ]; + console.log(lines.join('\n')); +} + +function parseArgs(argv) { + const opts = { ...DEFAULTS }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + switch (arg) { + case '--help': + case '-h': + opts.help = true; + break; + case '--host': + opts.host = argv[++i]; + break; + case '--port': + opts.port = parseInt(argv[++i], 10); + break; + case '--timeout-ms': + opts.timeoutMs = parseInt(argv[++i], 10); + break; + default: + console.error(`Unknown argument: ${arg} (see --help)`); + process.exit(2); + } + } + return opts; +} + +// --- Socket helpers ------------------------------------------------------ + +function openSocket(url, timeoutMs) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url); + const timer = setTimeout(() => { + try { + ws.close(); + } catch (_) { + /* ignore */ + } + reject(new Error(`open timed out after ${timeoutMs}ms`)); + }, timeoutMs); + + ws.addEventListener('open', () => { + clearTimeout(timer); + resolve(ws); + }); + ws.addEventListener('error', (err) => { + clearTimeout(timer); + reject(err); + }); + }); +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Collects frames received on `ws` for `windowMs`, parsing each as JSON where + * possible (raw string kept alongside on parse failure). Resolves with the + * list of frames observed in that window; never rejects on timeout (an empty + * list is a valid, meaningful result — "no frame arrived"). + */ +function collectFrames(ws, windowMs) { + return new Promise((resolve) => { + const frames = []; + let closed = false; + + const onMessage = (evt) => { + let parsed = null; + try { + parsed = JSON.parse(evt.data); + } catch (_) { + parsed = null; + } + frames.push({ raw: evt.data, json: parsed }); + }; + const onClose = () => { + closed = true; + }; + + ws.addEventListener('message', onMessage); + ws.addEventListener('close', onClose); + + setTimeout(() => { + ws.removeEventListener('message', onMessage); + ws.removeEventListener('close', onClose); + resolve({ frames, closed }); + }, windowMs); + }); +} + +function isErrorFrame(frame) { + return !!(frame && frame.json && typeof frame.json === 'object' && 'error' in frame.json); +} + +function isPushFrameFor(frame, endpointName) { + return !!( + frame && + frame.json && + typeof frame.json === 'object' && + frame.json.endpoint === endpointName && + !('error' in frame.json) + ); +} + +// --- Case runner ----------------------------------------------------------- + +/** + * Runs a single probe case: opens a fresh connection, sends `payload` (a + * pre-serialized string, so unparseable-JSON cases can be exercised too), + * collects frames for `timeoutMs`, and hands them to `assert(frames, closed)` + * which must return `{ pass: boolean, detail: string }`. + */ +async function runCase(name, opts, payload, assertFn) { + const url = `ws://${opts.host}:${opts.port}/`; + let ws; + try { + ws = await openSocket(url, opts.openTimeoutMs); + } catch (err) { + return { name, pass: false, detail: `connect failed: ${err.message || err}`, connectError: true }; + } + + try { + ws.send(payload); + const { frames, closed } = await collectFrames(ws, opts.timeoutMs); + const result = assertFn(frames, closed); + return { name, pass: result.pass, detail: result.detail }; + } catch (err) { + return { name, pass: false, detail: `unexpected error: ${err.message || err}`, connectError: true }; + } finally { + try { + ws.close(1000, 'probe-case-complete'); + } catch (_) { + /* ignore */ + } + } +} + +// --- Probe cases (one per VALD-01 test-map row) ----------------------------- + +async function caseUnknownEndpoint(opts) { + return runCase( + 'unknown-endpoint-name', + opts, + JSON.stringify({ action: 'subscribe', endpoints: ['definitelyNotARealEndpoint'] }), + (frames) => { + const errorFrame = frames.find(isErrorFrame); + if (!errorFrame) { + return { pass: false, detail: 'expected an error frame, got none' }; + } + const msg = JSON.stringify(errorFrame.json.error); + if (!msg.includes('definitelyNotARealEndpoint')) { + return { pass: false, detail: `error frame did not mention the offending name: ${msg}` }; + } + return { pass: true, detail: `got error frame mentioning offending name: ${msg}` }; + } + ); +} + +async function caseGetOnlyRejection(opts) { + return runCase( + 'get-only-rejection-setSwitches', + opts, + JSON.stringify({ action: 'subscribe', endpoints: ['setSwitches'] }), + (frames) => { + const errorFrame = frames.find(isErrorFrame); + if (!errorFrame) { + return { pass: false, detail: 'expected an immediate error frame for POST-only endpoint, got none' }; + } + const pushFrame = frames.find((f) => isPushFrameFor(f, 'setSwitches')); + if (pushFrame) { + return { pass: false, detail: 'unexpected push frame for setSwitches arrived (should be rejected at subscribe time)' }; + } + return { pass: true, detail: 'got error frame, no setSwitches push frame arrived' }; + } + ); +} + +async function caseMissingAction(opts) { + return runCase( + 'missing-action', + opts, + JSON.stringify({ endpoints: ['getWorldInv'] }), + (frames) => { + const errorFrame = frames.find(isErrorFrame); + if (!errorFrame) { + return { pass: false, detail: 'expected an error frame for missing action, got none' }; + } + return { pass: true, detail: `got error frame: ${JSON.stringify(errorFrame.json.error)}` }; + } + ); +} + +async function caseUnknownAction(opts) { + return runCase( + 'unknown-action', + opts, + JSON.stringify({ action: 'frobnicate', endpoints: ['getWorldInv'] }), + (frames) => { + const errorFrame = frames.find(isErrorFrame); + if (!errorFrame) { + return { pass: false, detail: 'expected an error frame for unknown action, got none' }; + } + return { pass: true, detail: `got error frame: ${JSON.stringify(errorFrame.json.error)}` }; + } + ); +} + +async function caseWrongTypedEndpoints(opts) { + return runCase( + 'wrong-typed-endpoints', + opts, + JSON.stringify({ action: 'subscribe', endpoints: 5 }), + (frames) => { + const errorFrame = frames.find(isErrorFrame); + if (!errorFrame) { + return { pass: false, detail: 'expected an error frame for wrong-typed endpoints field, got none' }; + } + return { pass: true, detail: `got error frame: ${JSON.stringify(errorFrame.json.error)}` }; + } + ); +} + +async function caseMixedValidInvalid(opts) { + return runCase( + 'mixed-valid-invalid-partial-success', + opts, + JSON.stringify({ action: 'subscribe', endpoints: [123, 'getWorldInv'] }), + (frames) => { + const errorFrame = frames.find(isErrorFrame); + if (!errorFrame) { + return { pass: false, detail: 'expected an error frame listing the invalid entry, got none' }; + } + const msg = JSON.stringify(errorFrame.json.error); + if (msg.includes('getWorldInv')) { + return { pass: false, detail: `error frame should list only the invalid entry, but mentions getWorldInv: ${msg}` }; + } + const pushFrame = frames.find((f) => isPushFrameFor(f, 'getWorldInv')); + if (!pushFrame) { + return { pass: false, detail: `got error frame (${msg}) but no getWorldInv push data arrived (partial success failed)` }; + } + return { pass: true, detail: `got error frame for invalid entry (${msg}) AND getWorldInv push data (partial success)` }; + } + ); +} + +async function caseUnparseableJson(opts) { + const url = `ws://${opts.host}:${opts.port}/`; + let ws; + try { + ws = await openSocket(url, opts.openTimeoutMs); + } catch (err) { + return { name: 'unparseable-json', pass: false, detail: `connect failed: ${err.message || err}`, connectError: true }; + } + + try { + ws.send('{not json'); + const { frames, closed } = await collectFrames(ws, opts.timeoutMs); + if (closed) { + return { name: 'unparseable-json', pass: false, detail: 'socket closed on unparseable JSON (D-02 requires it stay open)' }; + } + const errorFrame = frames.find(isErrorFrame); + if (!errorFrame) { + return { name: 'unparseable-json', pass: false, detail: 'expected an error frame for unparseable JSON, got none' }; + } + // Confirm the socket is genuinely still usable, not just un-closed-yet. + let stillOpen = true; + try { + ws.send(JSON.stringify({ action: 'unsubscribe', endpoints: ['getWorldInv'] })); + } catch (_) { + stillOpen = false; + } + if (!stillOpen || ws.readyState !== WebSocket.OPEN) { + return { name: 'unparseable-json', pass: false, detail: 'socket not usable/open after unparseable JSON error frame' }; + } + return { + name: 'unparseable-json', + pass: true, + detail: `got error frame (${JSON.stringify(errorFrame.json.error)}), socket stayed open`, + }; + } catch (err) { + return { name: 'unparseable-json', pass: false, detail: `unexpected error: ${err.message || err}`, connectError: true }; + } finally { + try { + ws.close(1000, 'probe-case-complete'); + } catch (_) { + /* ignore */ + } + } +} + +async function caseWellFormedRegression(opts) { + return runCase( + 'well-formed-regression', + opts, + JSON.stringify({ action: 'subscribe', endpoints: ['getWorldInv'] }), + (frames) => { + const errorFrame = frames.find(isErrorFrame); + if (errorFrame) { + return { pass: false, detail: `well-formed request produced an unexpected error frame: ${JSON.stringify(errorFrame.json.error)}` }; + } + const pushFrame = frames.find((f) => isPushFrameFor(f, 'getWorldInv')); + if (!pushFrame) { + return { pass: false, detail: 'expected getWorldInv push data, got none (and no error frame either)' }; + } + return { pass: true, detail: 'got getWorldInv push data, no error frame (regression check passed)' }; + } + ); +} + +// --- Main -------------------------------------------------------------- + +async function main() { + const opts = parseArgs(process.argv.slice(2)); + + if (opts.help) { + printUsage(); + process.exit(0); + } + + if (typeof WebSocket === 'undefined') { + console.error( + 'Global WebSocket is not available in this Node runtime. This probe ' + + 'requires Node 21+ (built-in WebSocket global). Detected: ' + process.version + ); + process.exit(2); + } + + console.log(`[ws-validation-probe] starting: host=${opts.host} port=${opts.port} timeoutMs=${opts.timeoutMs}`); + + const cases = [ + caseUnknownEndpoint, + caseGetOnlyRejection, + caseMissingAction, + caseUnknownAction, + caseWrongTypedEndpoints, + caseMixedValidInvalid, + caseUnparseableJson, + caseWellFormedRegression, + ]; + + const results = []; + let hadConnectError = false; + + for (const caseFn of cases) { + let result; + try { + result = await caseFn(opts); + } catch (err) { + result = { name: caseFn.name, pass: false, detail: `fatal error: ${err.message || err}`, connectError: true }; + } + results.push(result); + if (result.connectError) hadConnectError = true; + // Brief pause between cases to avoid overlapping push-loop timing artifacts. + await sleep(150); + } + + console.error('[ws-validation-probe] case results:'); + for (const r of results) { + const status = r.pass ? 'PASS' : 'FAIL'; + console.error(` [${status}] ${r.name}: ${r.detail}`); + } + + const failCount = results.filter((r) => !r.pass).length; + const passCount = results.length - failCount; + console.error(`[ws-validation-probe] summary: ${passCount}/${results.length} passed`); + + if (hadConnectError) { + console.error('[ws-validation-probe] FAIL: connection/protocol error occurred during the run.'); + process.exit(2); + } + + if (failCount > 0) { + console.error('[ws-validation-probe] FAIL: one or more validation cases regressed.'); + process.exit(1); + } + + console.error('[ws-validation-probe] PASS: all validation cases behaved as expected.'); + process.exit(0); +} + +main().catch((err) => { + console.error('[ws-validation-probe] fatal error:', err); + process.exit(2); +});