Add safe bank failure diagnostics for T5 and T6

Address #14 with request correlation, bounded parser reasons, startup version reporting, and rate-limited storage and settlement logs. Preserve accounting and API identity contracts, extend portable tests, and update release versions and rollout guidance.
This commit is contained in:
2026-10-13 10:02:36 -04:00
parent ab989395f7
commit 463b4bcc51
19 changed files with 804 additions and 78 deletions
+20
View File
@@ -109,3 +109,23 @@ decisions and strict response validation for the new bank script. Build and
install the DLL, then restart the full process. No upstream pins or external
dependencies changed. See [bank setup and recovery](../../README.md#player-bank-t5-and-t6).
Portable checks: `python3 tools/test_plugin_bank.py`.
## Bank diagnostics (TWS 1.2.0)
`bankVersion()` returns the loaded DLL's TWS component version, independently of
upstream pins and the native loader's unchanged `plugin_version()` value.
`bankResponse()` now also returns `reason`: `ok` on success, otherwise one of
`empty_body`, `body_limit`, `invalid_json`, `schema`, `field_limit`, `state`,
`amount_range`, `ok_state`, `missing_id`, or `zero_transfer`. Invalid responses
clear all parsed fields. Existing acknowledgment acceptance rules are unchanged.
The returned `code` on valid responses remains API data; bank scripts allowlist
it before logging. Callers must never log arbitrary response fields.
Native journal logs name load/write/remove, limit identical failures to one
per 30 seconds, and log recovery with suppressed counts. Exception text, journal
contents and credentials are excluded. The new scripts require this DLL even
when disabled by dvar because `bankVersion()` must link. No new third-party
dependencies or upstream pin changes are required. Rebuild/install the DLL and
restart the full server process; see the [diagnostic rollout](../../README.md#bank-diagnostics--tws-14).
Portable checks: `python3 tools/test_plugin_bank.py` executes the real parser,
storage and native registration adapter with sanitizers and engine shims.
@@ -3,6 +3,8 @@
#include "gsc.hpp"
#include "game/scripting/array.hpp"
#include <utils/bank.hpp>
#include <array>
#include <chrono>
namespace bank
{
@@ -13,11 +15,33 @@ namespace bank
return {"tws-bank/t6", server, endpoint};
}
void storage_error()
// Only fixed operation names are printed, never exception text or paths
// from private configuration. Per-operation state has bounded memory.
void storage_result(size_t operation, bool failed)
{
// Do not echo parse exceptions: their text can contain player data.
printf("^1[tws_bank] Outbox operation failed; check tws-bank/t6 files, disk space and permissions. Existing reports retained.\n");
struct status
{
bool failed = false;
std::chrono::steady_clock::time_point logged{};
unsigned suppressed = 0;
};
static std::array<status, 3> states;
constexpr const char* names[] = {"load", "write", "remove"};
auto& state = states[operation];
const auto now = std::chrono::steady_clock::now();
if (failed && state.failed && now - state.logged < std::chrono::seconds(30))
{
++state.suppressed;
return;
}
if (failed || state.failed)
printf("[tws_bank] native_storage operation=%s outcome=%s suppressed=%u\n",
names[operation], failed ? "storage_failure" : "recovered", state.suppressed);
state.failed = failed;
state.logged = now;
state.suppressed = 0;
}
}
class component final : public component_interface
@@ -25,6 +49,7 @@ namespace bank
public:
void on_startup([[maybe_unused]] plugin::plugin* plugin) override
{
gsc::function::add("bankversion", []() { return std::string("1.2.0"); });
gsc::function::add("bankload", [](const std::string& server, const std::string& endpoint) -> scripting::script_value
{
try
@@ -38,25 +63,27 @@ namespace bank
entry["body"] = value.body;
entries.emplace_back(entry);
}
storage_result(0, false);
return entries;
}
catch (...) { storage_error(); return {}; }
catch (...) { storage_result(0, true); return {}; }
});
gsc::function::add("bankstore", [](const std::string& server, const std::string& endpoint, const std::string& body)
{
try { spool(server, endpoint).store(body); return true; }
catch (...) { storage_error(); return false; }
try { spool(server, endpoint).store(body); storage_result(1, false); return true; }
catch (...) { storage_result(1, true); return false; }
});
gsc::function::add("bankremove", [](const std::string& server, const std::string& endpoint, const std::string& id)
{
try { spool(server, endpoint).erase(id); return true; }
catch (...) { storage_error(); return false; }
try { spool(server, endpoint).erase(id); storage_result(2, false); return true; }
catch (...) { storage_result(2, true); return false; }
});
gsc::function::add("bankresponse", [](const std::string& response)
{
const auto ack = utils::bank::parse_acknowledgement(response);
scripting::array result;
result["valid"] = ack.valid;
result["reason"] = ack.reason;
result["state"] = ack.state;
result["code"] = ack.code;
result["request_id"] = ack.id;
+25 -13
View File
@@ -29,30 +29,42 @@ namespace utils::bank
acknowledgement parse_acknowledgement(const std::string& response)
{
acknowledgement result;
if (response.empty() || response.size() >= max_body_size) return result;
auto invalid = [](const char* reason)
{
acknowledgement value;
value.reason = reason;
return value;
};
if (response.empty()) return invalid("empty_body");
if (response.size() >= max_body_size) return invalid("body_limit");
const auto data = nlohmann::json::parse(response, nullptr, false);
if (!data.is_object()) return result;
if (data.is_discarded()) return invalid("invalid_json");
if (!data.is_object()) return invalid("schema");
try
{
if (!data.at("ok").is_boolean()) return result;
acknowledgement result;
if (!data.at("ok").is_boolean()) return invalid("schema");
result.state = data.at("state").get<std::string>();
result.code = data.at("code").get<std::string>();
result.id = data.at("request_id").get<std::string>();
if (result.id.size() > 64 || result.code.size() > 64 ||
(result.state != "balance" && result.state != "prepared" && result.state != "committed" &&
result.state != "cancelled" && result.state != "rejected")) return {};
if (result.id.size() > 64 || result.code.size() > 64)
return invalid("field_limit");
if (result.state != "balance" && result.state != "prepared" && result.state != "committed" &&
result.state != "cancelled" && result.state != "rejected") return invalid("state");
for (const auto* key : {"amount", "balance"})
if (!data.at(key).is_number_integer() || data.at(key) < 0 || data.at(key) > 1000000) return {};
if (!data.at(key).is_number_integer() || data.at(key) < 0 || data.at(key) > 1000000)
return invalid("amount_range");
result.amount = data.at("amount").get<int>();
result.balance = data.at("balance").get<int>();
if (data.at("ok").get<bool>() != (result.state != "rejected") ||
(result.state != "balance" && result.id.empty()) ||
((result.state == "prepared" || result.state == "committed") && result.amount == 0)) return {};
if (data.at("ok").get<bool>() != (result.state != "rejected")) return invalid("ok_state");
if (result.state != "balance" && result.id.empty()) return invalid("missing_id");
if ((result.state == "prepared" || result.state == "committed") && result.amount == 0)
return invalid("zero_transfer");
result.valid = true;
result.reason = "ok";
return result;
}
catch (...) { return {}; }
return result;
catch (...) { return invalid("schema"); }
}
outbox::outbox(std::filesystem::path root, std::string server, std::string endpoint)
@@ -10,6 +10,8 @@ namespace utils::bank
{
bool valid = false;
std::string state, code, id;
// Fixed diagnostic vocabulary; never includes response text.
std::string reason = "schema";
int amount = 0, balance = 0;
};
acknowledgement parse_acknowledgement(const std::string& response);