Preserve and retry telemetry match boundaries across restarts
This commit is contained in:
@@ -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.`);
|
||||
@@ -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)
|
||||
@@ -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";
|
||||
}
|
||||
Reference in New Issue
Block a user