Preserve and retry telemetry match boundaries across restarts

This commit is contained in:
2026-10-12 12:25:14 -04:00
parent 1121b44857
commit 158916b664
16 changed files with 1603 additions and 142 deletions
+283
View File
@@ -0,0 +1,283 @@
// Run the actual reporter queue/response functions with HTTP, disk and VM shims.
// This validates state transitions, not engine linking or live gameplay.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
const root = new URL('../', import.meta.url);
let checks = 0;
function check(name, run) {
try { run(); checks++; }
catch (error) { throw new Error(name, { cause: error }); }
}
function functions(source) {
const literals = [];
const protectedSource = source.replace(/"(?:\\.|[^"\\])*"|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, token => {
if (token.startsWith('/')) return '';
literals.push(token);
return `__literal_${literals.length - 1}__`;
});
const found = new Map();
const headers = /^(telemetry_\w+)\(([^\n]*)\)\s*\n\{/gm;
for (let match; (match = headers.exec(protectedSource));) {
let end = headers.lastIndex, depth = 1;
while (depth && end < protectedSource.length) {
const ch = protectedSource[end++];
if (ch === '{') depth++;
if (ch === '}') depth--;
}
assert.equal(depth, 0);
let body = protectedSource.slice(headers.lastIndex, end - 1);
const locals = new Set([...body.matchAll(/(?<![\w.])(\w+)\s*(?:[+*/%-]?=(?!=)|\+\+|--)/g)].map(x => x[1]));
body = body.replace(/\.size\b/g, '.length').replace(/http::request/g, 'httpPost');
body = body.replace(/level thread \w+\(\);/g, '').replace(/level notify\(/g, 'notify(');
body = body.replace(/level waittill\(([^;]+)\);/g, 'yield $1;');
body = body.replace(/wait \(([^;]+)\);/g, 'yield $1;');
body = body.replace(/request waittill\([^;]+\);/g, 'var [response, success, error, diagnostics] = httpComplete(request);');
body = body.replace(/__literal_(\d+)__/g, (_, index) => literals[Number(index)]);
found.set(match[1], `function${match[1] === 'telemetry_sender' ? '*' : ''} ${match[1]}(${match[2]}) { var ${[...locals].join(', ') || 'unused'}; ${body} }`);
}
return found;
}
function disk() {
return { entries: [], failStore: false, failRemove: false, calls: [] };
}
let nextMatch = 0;
function engine(game, storage = disk()) {
const source = readFileSync(new URL(`${game}/scripts/tws_${game}_telemetry.gsc`, root), 'utf8');
const all = functions(source);
const excluded = new Set(['telemetry_players_json', 'telemetry_stat', 'telemetry_heartbeat_watcher',
'telemetry_round_watcher', 'telemetry_game_end_watcher', 'telemetry_watchdog', 'telemetry_log']);
const sent = [], responses = [], logs = [], notifications = [];
const queue = storage.entries.map(e => ({ ...e, stored: true, captured: 1000 }));
const c = vm.createContext({
level: { telemetry_server: 'test-server', telemetry_url: 'https://test.invalid/telemetry', telemetry_token: 'test-token',
telemetry_started: false, telemetry_ended: false, telemetry_last_round: -1, round_number: 0,
telemetry_queue: queue, telemetry_queue_read: 0, telemetry_queue_write: queue.length,
telemetry_sequence: 0, telemetry_failed: 0, telemetry_completed: 0, telemetry_accepted: 0,
telemetry_http_rejected: 0, telemetry_failure_streak: 0, telemetry_sender_state: 'starting' },
GetDvar: key => key === 'mapname' ? 'test-map' : '',
getTime: () => 1000,
isDefined: value => value !== undefined && value !== null,
int: Math.trunc,
jsonSerialize: JSON.stringify,
getSubStr: (value, start, end) => value.slice(start, end),
strTok: (value, delimiter) => value.split(delimiter),
telemetry_players_json: () => '[]',
telemetry_log: (verbosity, text) => logs.push(text),
notify: event => notifications.push(event),
telemetryNewMatch: () => `boot-${++nextMatch}`,
telemetryStore: (server, url, body) => {
storage.calls.push('store');
if (storage.failStore) return false;
const data = JSON.parse(body);
const existing = storage.entries.find(e => e.match === data.match && e.id === data.seq);
if (existing) assert.equal(existing.body, body);
else storage.entries.push({ match: data.match, id: data.seq, event: data.event, round: data.round ?? 0, body });
return true;
},
telemetryRemove: (server, url, match, seq) => {
storage.calls.push('remove');
if (storage.failRemove) return false;
storage.entries = storage.entries.filter(e => e.match !== match || e.id !== seq);
return true;
},
// Native parser behavior is tested separately against the compiled C++.
telemetryResponse: response => {
try {
const data = JSON.parse(response);
return { accepted: data?.ok === true && response.length < 20480, match_id: String(data?.telemetry?.timing?.matchId ?? '') };
} catch { return { accepted: false, match_id: '' }; }
},
httpPost: (url, options) => {
storage.calls.push('send');
sent.push({ ...options, headers: { ...options.headers } });
return responses[0]?.noHandle ? undefined : {};
},
httpComplete: () => {
assert(responses.length, 'unexpected HTTP request');
const response = responses.shift();
response.during?.();
return [response.body ?? '{"ok":true,"telemetry":{"timing":{"matchId":921}}}', response.success ?? true,
response.error, response.missingDiagnostics ? undefined : { http_status: response.status ?? 200 }];
},
});
vm.runInContext([...all].filter(([name]) => !excluded.has(name)).map(([, code]) => code).join('\n'), c);
return { c, all, source, sent, responses, logs, storage, notifications };
}
for (const game of ['t5', 't6']) {
check(`${game}: match identity, sequence and capture before send`, () => {
const { c, storage, sent, responses } = engine(game);
c.telemetry_game_start(); c.telemetry_game_start();
c.level.round_number = 1;
c.telemetry_report_round(); c.telemetry_enqueue('heartbeat'); c.telemetry_enqueue('game_end');
assert.equal(c.level.telemetry_queue_write, 4);
const events = c.level.telemetry_queue.map(e => JSON.parse(e.body));
assert.deepEqual(Array.from(events, e => e.seq), [1, 2, 3, 4]);
assert(events.every(e => e.match === events[0].match));
assert(!('round' in events[0]));
assert(events.every(e => Array.isArray(e.players)));
assert.equal(storage.entries.length, 2);
assert.equal(sent.length, 0);
responses.push({}, {}, {}, {});
c.level.telemetry_ended = true;
assert.equal(c.telemetry_sender().next().done, true);
assert.equal(storage.entries.length, 0);
assert.equal(c.level.telemetry_accepted, 4);
assert(sent.every(e => e.connect_timeout_ms === 5000 && e.timeout_ms === 15000));
assert.equal(sent[0].headers.Authorization, 'Bearer test-token');
assert(storage.calls.indexOf('store') < storage.calls.indexOf('send'));
});
check(`${game}: retries retain FIFO head and exact snapshot, coalesce heartbeats`, () => {
const { c, sent, responses, logs } = engine(game);
c.telemetry_game_start();
const initial = c.level.telemetry_queue[0].body;
responses.push({ success: false, error: 'timeout' }, { status: 429 }, { status: 503 }, {}, {}, {}, {});
const sender = c.telemetry_sender();
assert.equal(sender.next().value, 1);
c.telemetry_enqueue('heartbeat');
c.level.round_number = 2; c.telemetry_report_round();
c.telemetry_enqueue('heartbeat'); c.telemetry_enqueue('heartbeat');
c.telemetry_enqueue('game_end'); c.level.telemetry_ended = true;
assert.equal(sender.next().value, 2);
assert.equal(sender.next().value, 4);
assert.equal(sender.next().done, true);
const events = sent.map(s => JSON.parse(s.body));
assert(sent.slice(0, 4).every(s => s.body === initial));
assert.deepEqual(events.slice(4).map(e => [e.event, e.seq]), [['round_change', 3], ['heartbeat', 5], ['game_end', 6]]);
assert(logs.some(line => line.includes('ec_match_id=921')));
assert(logs.some(line => line.includes('API recovered')));
});
check(`${game}: response classification and permanent rejection`, () => {
const { c, responses, storage } = engine(game);
for (const status of [200, 201, 204, 299]) assert.equal(c.telemetry_response_outcome('{"ok":true}', { http_status: status }), 'accepted');
for (const status of [0, 429, 500, 502, 503, 599]) assert.equal(c.telemetry_response_outcome('{"ok":true}', { http_status: status }), 'retry');
for (const status of [301, 400, 401, 403, 404, 408, 415, 422]) assert.equal(c.telemetry_response_outcome('{"ok":true}', { http_status: status }), 'drop');
for (const body of ['', '<html>', '{"ok":false}', '{"ok":1}', '{"ok":"true"}']) assert.equal(c.telemetry_response_outcome(body, { http_status: 200 }), 'retry');
assert.equal(c.telemetry_response_outcome('{"ok":true}', undefined), 'retry');
c.telemetry_game_start(); responses.push({ status: 401 });
assert.equal(c.telemetry_sender().next().value, 'telemetry_queued');
assert.equal(storage.entries.length, 0);
assert.equal(c.level.telemetry_accepted, 0);
});
check(`${game}: backoff is capped and a missing handle retains the boundary`, () => {
const { c, responses, storage } = engine(game);
c.telemetry_game_start(); responses.push({ noHandle: true });
const sender = c.telemetry_sender();
const delays = Array.from({ length: 9 }, () => sender.next().value);
assert.deepEqual(delays, [1, 2, 4, 8, 16, 30, 30, 30, 30]);
assert.equal(storage.entries.length, 1);
assert.equal(c.level.telemetry_queue_read, 0);
});
check(`${game}: failed ordinary report is skipped and in-flight heartbeat stays stable`, () => {
const { c, responses, sent } = engine(game);
c.level.telemetry_match = 'match';
c.telemetry_enqueue('heartbeat');
responses.push({ success: false, during: () => { c.telemetry_enqueue('heartbeat'); c.telemetry_enqueue('heartbeat'); } }, {});
assert.equal(c.telemetry_sender().next().value, 'telemetry_queued');
assert.deepEqual(sent.map(s => JSON.parse(s.body).seq), [1, 3]);
});
check(`${game}: replay survives map/process reset and precedes new start`, () => {
const first = engine(game);
first.c.telemetry_game_start(); first.c.telemetry_enqueue('heartbeat'); first.c.telemetry_enqueue('game_end');
first.responses.push({ success: false }); first.c.telemetry_sender().next();
const replay = engine(game, first.storage);
replay.c.telemetry_game_start();
replay.responses.push({}, {}, {});
assert.equal(replay.c.telemetry_sender().next().value, 'telemetry_queued');
const bodies = replay.sent.map(s => JSON.parse(s.body));
assert.deepEqual(bodies.map(b => b.event), ['game_start', 'game_end', 'game_start']);
assert.equal(bodies[0].match, bodies[1].match);
assert.notEqual(bodies[0].match, bodies[2].match);
assert.equal(bodies[2].seq, 1);
assert.equal(replay.sent[0].body, first.sent[0].body);
assert.equal(first.storage.entries.length, 0);
assert(replay.source.indexOf('telemetryLoad(') < replay.source.indexOf('register_spawn_handler('));
});
check(`${game}: disk failure preserves ordering and blocks sending`, () => {
const { c, storage, sent, responses } = engine(game);
storage.failStore = true;
c.telemetry_game_start(); c.telemetry_enqueue('game_end');
const sender = c.telemetry_sender();
assert.equal(sender.next().value, 1);
assert.equal(sent.length, 0);
storage.failStore = false;
c.telemetry_persist_pending();
assert.deepEqual(storage.entries.map(e => e.event), ['game_start', 'game_end']);
storage.failRemove = true;
responses.push({});
assert.equal(sender.next().value, 2);
assert.equal(sent.length, 1);
assert.equal(sender.next().value, 4);
assert.equal(sent.length, 1); // Only retry deletion after an acknowledgment.
storage.failRemove = false; responses.push({});
assert.equal(sender.next().value, 'telemetry_queued');
assert.equal(storage.entries.length, 0);
assert.equal(sent.length, 2);
});
check(`${game}: end captured behind a stalled round survives map unload`, () => {
const first = engine(game);
first.c.telemetry_game_start(); first.responses.push({});
first.c.telemetry_sender().next();
assert.equal(first.storage.entries.length, 0);
first.c.level.round_number = 12; first.c.telemetry_report_round();
const unload = new Error('map unloaded during HTTP wait');
first.responses.push({ during: () => {
first.c.telemetry_enqueue('game_end');
throw unload;
} });
assert.throws(() => first.c.telemetry_sender().next(), error => error === unload);
assert.deepEqual(first.storage.entries.map(e => e.event), ['game_end']);
const replay = engine(game, first.storage);
replay.c.telemetry_game_start();
replay.responses.push({ status: 500 }, {}, {});
const sender = replay.c.telemetry_sender();
assert.equal(sender.next().value, 1);
assert.equal(sender.next().value, 'telemetry_queued');
const events = replay.sent.map(s => JSON.parse(s.body));
assert.deepEqual(events.map(e => e.event), ['game_end', 'game_end', 'game_start']);
assert.equal(events[0].round, 12);
assert.equal(replay.sent[0].body, replay.sent[1].body);
assert.notEqual(events[0].match, events[2].match);
});
check(`${game}: acknowledgment lost at restart replays the identical end`, () => {
const first = engine(game);
first.c.telemetry_game_start(); first.responses.push({});
first.c.telemetry_sender().next();
first.c.telemetry_enqueue('game_end');
first.storage.failRemove = true;
first.responses.push({});
assert.equal(first.c.telemetry_sender().next().value, 1);
assert.equal(first.c.level.telemetry_accepted, 2);
first.storage.failRemove = false;
const replay = engine(game, first.storage);
replay.responses.push({});
replay.c.telemetry_sender().next();
assert.equal(replay.sent[0].body, first.sent[1].body);
assert.equal(first.storage.entries.length, 0);
});
}
check('paired queue and delivery implementations match', () => {
const t5 = engine('t5').all, t6 = engine('t6').all;
for (const name of ['telemetry_enqueue', 'telemetry_queue_push', 'telemetry_critical', 'telemetry_persist_pending',
'telemetry_sender', 'telemetry_process', 'telemetry_retry_delay', 'telemetry_send', 'telemetry_response_outcome'])
assert.equal(t5.get(name), t6.get(name), name);
for (const file of ['telemetry.hpp', 'telemetry.cpp']) {
const get = game => readFileSync(new URL(`${game}/plugins/tws-${game}-gsc-utils/src/utils/${file}`, root), 'utf8');
assert.equal(get('t5'), get('t6'), file);
}
});
console.log(`PASS ${checks} telemetry queue/response checks (both games). VM linking/gameplay still require Plutonium.`);
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env python3
"""Test native telemetry storage/acknowledgments with ASan/UBSan, without the VM.
Uses deps/json/single_include/nlohmann/json.hpp if restored, or --json-header
pointing to the single header at the revision in the plugin's upstream.json.
"""
import argparse
import os
from pathlib import Path
import re
import shutil
import subprocess
import tempfile
ROOT = Path(__file__).resolve().parents[1]
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--json-header", help="Header path; {game} expands to t5/t6 for their respective pinned headers")
args = parser.parse_args()
with tempfile.TemporaryDirectory(prefix="tws-telemetry-") as temp:
directory = Path(temp)
for game in ("t5", "t6"):
plugin = ROOT / game / "plugins" / f"tws-{game}-gsc-utils"
header = Path(args.json_header.format(game=game)) if args.json_header else plugin / "deps/json/single_include/nlohmann/json.hpp"
if not header.is_file():
parser.error(f"Missing {header}; restore dependencies or pass --json-header")
shutil.copyfile(header, directory / "json.hpp")
(directory / "stdinc.hpp").write_text("#include <algorithm>\n#include <fstream>\n#include <sstream>\n#include <json.hpp>\n")
executable = directory / game
subprocess.run([os.environ.get("CXX", "c++"), "-std=c++20", "-Wall", "-Wextra", "-Werror",
"-fsanitize=address,undefined", "-I", str(directory), "-I", str(plugin / "src/utils"),
str(plugin / "src/utils/telemetry.cpp"), str(ROOT / "tools/tests/telemetry_cases.cpp"),
"-o", str(executable)], check=True)
work = directory / f"{game}-data"
subprocess.run([str(executable), str(work)], check=True)
# Separate OS processes prove snapshots/identities don't depend on VM or
# DLL memory. A newly booted producer recovers before its own start.
subprocess.run([str(executable), str(work), "produce"], check=True)
subprocess.run([str(executable), str(work), "recover"], check=True)
subprocess.run([str(executable), str(work), "empty"], check=True)
# Compile the actual registration adapter as well, substituting only
# engine interfaces. This does not validate the Windows ABI/VM linker.
stub = (ROOT / "tools/tests/http_component_stub.hpp").read_text()
stub = stub.replace("std::vector<script_value> get_keys() const", "void emplace_back(const script_value& value) { (*data)[std::to_string(data->size())] = value; }\n std::vector<script_value> get_keys() const")
(directory / "component_stub.hpp").write_text(stub)
component = re.sub(r'^#include[^\n]*\n', '', (plugin / "src/component/telemetry.cpp").read_text(), flags=re.MULTILINE)
source = directory / "component.cpp"
source.write_text('#include "component_stub.hpp"\n#include "telemetry.hpp"\n' + component)
subprocess.run([os.environ.get("CXX", "c++"), "-std=c++20", "-Wall", "-Wextra", "-Werror", "-fsyntax-only",
"-I", str(directory), "-I", str(plugin / "src/utils"), str(source)], check=True)
print(f"PASS {game}: native storage, acknowledgment, process restart and component checks", flush=True)
+126
View File
@@ -0,0 +1,126 @@
#include "telemetry.hpp"
#include <json.hpp>
#include <cassert>
#include <fstream>
#include <iostream>
#include <set>
namespace fs = std::filesystem;
using utils::telemetry::outbox;
using utils::telemetry::new_match_key;
using utils::telemetry::parse_acknowledgement;
const std::string server = "t6_zombie_buried_1";
const std::string endpoint = "https://example.test/api/telemetry";
std::string body(const std::string& match, int seq, const std::string& event)
{
nlohmann::json value = {{"server", server}, {"map", "zm_buried"}, {"match", match},
{"seq", seq}, {"event", event}, {"players", nlohmann::json::array()}};
if (event != "game_start") value["round"] = 12;
return value.dump();
}
template <typename F> void must_fail(F action)
{
bool failed = false;
try { action(); } catch (const std::exception&) { failed = true; }
assert(failed);
}
int main(int argc, char** argv)
{
assert(argc >= 2);
const fs::path root(argv[1]);
const outbox queue(root, server, endpoint);
if (argc == 3)
{
const std::string mode(argv[2]);
if (mode == "produce")
{
const auto match = new_match_key();
queue.store(body(match, 1, "game_start"));
queue.store(body(match, 14, "game_end"));
}
else if (mode == "recover")
{
const auto reports = queue.load();
assert(reports.size() == 2);
const auto next = new_match_key();
assert(reports[0].match != next);
queue.store(body(next, 1, "game_start"));
const auto all = queue.load();
assert(all.size() == 3 && all[0].event == "game_start" && all[1].event == "game_end");
assert(all[0].match == all[1].match && all[2].match == next);
for (const auto& value : all) queue.erase(value.match, value.seq);
}
else assert(mode == "empty" && queue.load().empty());
return 0;
}
fs::create_directories(root);
assert(queue.load().empty());
std::set<std::string> keys;
for (int i = 0; i < 1000; ++i)
{
const auto key = new_match_key();
assert(!key.empty() && key.size() <= 64 && keys.insert(key).second);
}
const auto match = new_match_key();
const auto start = body(match, 1, "game_start");
const auto end = body(match, 30, "game_end");
queue.store(start);
queue.store(end);
queue.store(start); // A retry cannot add a duplicate or reorder the queue.
const auto replay = outbox(root, server, endpoint).load();
assert(replay.size() == 2 && replay[0].body == start && replay[1].body == end);
assert(replay[0].seq == 1 && replay[1].seq == 30 && replay[1].round == 12);
assert(outbox(root, "other-server", endpoint).load().empty());
assert(outbox(root, server, "https://different.test/api").load().empty());
auto changed = nlohmann::json::parse(start);
changed["map"] = "another-map";
must_fail([&] { queue.store(changed.dump()); });
must_fail([&] { queue.store(body(match, 0, "game_start")); });
must_fail([&] { queue.store(body(match, 2, "game_start")); });
must_fail([&] { queue.store(body(match, 3, "heartbeat")); });
must_fail([&] { queue.store("<html>error</html>"); });
changed = nlohmann::json::parse(end);
changed["server"] = "wrong-server";
must_fail([&] { queue.store(changed.dump()); });
const auto file = fs::directory_iterator(root)->path();
auto temporary = file;
temporary += ".tmp";
{ std::ofstream stream(temporary); stream << "interrupted write"; }
assert(queue.load().size() == 2); // Incomplete temp file never replaces committed data.
fs::remove(temporary);
fs::create_directory(temporary); // Force replacement writes to fail even under root.
must_fail([&] { queue.erase(match, 1); });
must_fail([&] { queue.store(body(new_match_key(), 1, "game_start")); });
assert(queue.load().size() == 2);
fs::remove(temporary);
queue.erase(match, 1);
queue.erase(match, 1); // Cleanup is idempotent after an uncertain completion.
assert(queue.load().size() == 1 && queue.load()[0].body == end);
queue.erase(match, 30);
assert(queue.load().empty());
{ std::ofstream stream(file); stream << "corrupt"; }
must_fail([&] { queue.load(); });
must_fail([&] { queue.store(start); });
assert(fs::file_size(file) == 7); // Never silently overwrite corrupt retained data.
fs::remove(file);
for (const std::string response : {"", "<html>bad gateway</html>", "{", "null", "[]", "true",
"{}", "{\"ok\":false}", "{\"ok\":1}", "{\"ok\":\"true\"}", "{\"ok\":true}garbage"})
assert(!parse_acknowledgement(response).accepted);
const auto ack = parse_acknowledgement(R"({"ok":true,"telemetry":{"timing":{"matchId":921}}})");
assert(ack.accepted && ack.match_id == "921");
assert(parse_acknowledgement(R"({"ok":true})").accepted);
const auto large_id = parse_acknowledgement(R"({"ok":true,"telemetry":{"timing":{"matchId":9223372036854775807}}})");
assert(large_id.match_id == "9223372036854775807");
assert(parse_acknowledgement(R"({"ok":true,"telemetry":null})").match_id.empty());
assert(parse_acknowledgement(R"({"ok":true,"telemetry":{"timing":{"matchId":"wrong"}}})").match_id.empty());
assert(!parse_acknowledgement("{\"ok\":true}" + std::string(0x5000, ' ')).accepted);
std::cout << "PASS native telemetry fixtures\n";
}