mirror of
https://github.com/SpeedBreakerProject/speedbreaker.git
synced 2026-10-17 22:01:34 +02:00
Co-authored-by: aleszczynskig <36401256+aleszczynskig@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
417 lines
20 KiB
C++
417 lines
20 KiB
C++
// SpeedBreaker tests. GPL-3.0-or-later (see COPYING).
|
|
//
|
|
// A requested termination quits the game cleanly, and only a session that
|
|
// didn't end cleanly leaves its crash marker (platform/quit_signals.h,
|
|
// report/crash_marker.h). v0.1.5 left the empty marker after every session
|
|
// (a Steam Frame session closed from Steam left one, as if it had crashed),
|
|
// and SIGHUP ended it at once, saving nothing.
|
|
// - Signals, each case in a child process: SIGTERM, SIGINT and SIGHUP
|
|
// become a quit request and the process lives on to quit; the first
|
|
// request is the one kept; a signal ignored at launch (nohup) stays
|
|
// ignored; SDL's own handler would leave them to us (it takes a signal
|
|
// only while its action is the default); SIGKILL still kills. Controls:
|
|
// without the handlers SIGHUP kills (v0.1.5).
|
|
// - The marker: made empty, removed by a clean end; what the next launch
|
|
// finds after a clean end, a SIGKILL, a crash report it couldn't rename,
|
|
// and beside the current session's own. Then main()'s order end to end in
|
|
// child processes: SIGTERM -> the quit -> no marker; SIGKILL -> marker.
|
|
// - The optional argument is the runtime folder: main.cpp, presenter.cpp
|
|
// and crash.cpp are read to check the handlers come before SDL, the frame
|
|
// loop leaves on a request, every clean end clears the marker (last, just
|
|
// before the exit) and a crash being reported keeps it; then planted
|
|
// mutations of them that must fail.
|
|
//
|
|
// Build by hand: c++ -std=c++20 -O2 -I runtime tests/clean_exit_test.cpp -o build/clean_exit_test
|
|
// build/clean_exit_test [<scratch folder>] [runtime]
|
|
#include <platform/quit_signals.h>
|
|
#include <report/crash_marker.h>
|
|
|
|
#include <chrono>
|
|
#include <csignal>
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <cstring>
|
|
#include <fstream>
|
|
#include <functional>
|
|
#include <sstream>
|
|
#include <string>
|
|
#include <thread>
|
|
#include <vector>
|
|
|
|
#include <sys/wait.h>
|
|
#include <unistd.h>
|
|
|
|
static int g_failures = 0;
|
|
#define CHECK(cond, ...) do { if (!(cond)) { g_failures++; printf(" FAIL %s:%d: %s: ", __FILE__, __LINE__, #cond); printf(__VA_ARGS__); printf("\n"); } } while (0)
|
|
|
|
namespace fs = std::filesystem;
|
|
|
|
namespace
|
|
{
|
|
// `body` in a child process: its exit code (0: passed), or 128 + the
|
|
// signal that killed it.
|
|
int InChild(const std::function<int()>& body)
|
|
{
|
|
fflush(stdout);
|
|
const pid_t pid = fork();
|
|
if (pid == 0)
|
|
_exit(body());
|
|
int status = 0;
|
|
waitpid(pid, &status, 0);
|
|
return WIFEXITED(status) ? WEXITSTATUS(status) : WIFSIGNALED(status) ? 128 + WTERMSIG(status) : 255;
|
|
}
|
|
|
|
bool WaitFor(const std::function<bool()>& done, int ms)
|
|
{
|
|
for (int i = 0; i < ms; i++)
|
|
{
|
|
if (done())
|
|
return true;
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(1));
|
|
}
|
|
return done();
|
|
}
|
|
|
|
void Signals()
|
|
{
|
|
printf("signals:\n");
|
|
for (int sig : quitsignals::kSignals)
|
|
{
|
|
const int rc = InChild([sig] {
|
|
quitsignals::Install();
|
|
raise(sig);
|
|
return quitsignals::Requested() == sig ? 0 : 1;
|
|
});
|
|
CHECK(rc == 0, "%s: a quit request, and the game lives on to quit (child: %d)", quitsignals::Name(sig), rc);
|
|
}
|
|
// From another process (as Steam, systemd or kill send it), while
|
|
// the game runs.
|
|
{
|
|
fflush(stdout);
|
|
const pid_t pid = fork();
|
|
if (pid == 0)
|
|
{
|
|
quitsignals::Install();
|
|
const bool seen = WaitFor([] { return quitsignals::Requested() != 0; }, 5000);
|
|
_exit(seen && quitsignals::Requested() == SIGTERM ? 0 : 1);
|
|
}
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
|
kill(pid, SIGTERM);
|
|
int status = 0;
|
|
waitpid(pid, &status, 0);
|
|
CHECK(WIFEXITED(status) && WEXITSTATUS(status) == 0, "SIGTERM from another process: seen, the child quit by itself");
|
|
}
|
|
{
|
|
const int rc = InChild([] {
|
|
quitsignals::Install();
|
|
raise(SIGTERM);
|
|
raise(SIGHUP);
|
|
raise(SIGINT);
|
|
return quitsignals::Requested() == SIGTERM ? 0 : 1;
|
|
});
|
|
CHECK(rc == 0, "the first request is kept; later ones change nothing (child: %d)", rc);
|
|
}
|
|
{
|
|
const int rc = InChild([] {
|
|
signal(SIGHUP, SIG_IGN); // nohup
|
|
quitsignals::Install();
|
|
raise(SIGHUP);
|
|
struct sigaction now{};
|
|
sigaction(SIGHUP, nullptr, &now);
|
|
return quitsignals::Requested() == 0 && now.sa_handler == SIG_IGN ? 0 : 1;
|
|
});
|
|
CHECK(rc == 0, "SIGHUP ignored at launch (nohup) stays ignored (child: %d)", rc);
|
|
}
|
|
{
|
|
// SDL_EventSignal_Init (SDL 3.4.16, events/SDL_quit.c) puts its
|
|
// handler only where the action is the default.
|
|
const int rc = InChild([] {
|
|
quitsignals::Install();
|
|
for (int sig : quitsignals::kSignals)
|
|
{
|
|
struct sigaction action{};
|
|
sigaction(sig, nullptr, &action);
|
|
if (action.sa_handler == SIG_DFL && reinterpret_cast<void*>(action.sa_sigaction) == reinterpret_cast<void*>(SIG_DFL))
|
|
return 1; // SDL would take it
|
|
}
|
|
return 0;
|
|
});
|
|
CHECK(rc == 0, "SDL leaves the three signals to the game's handler (child: %d)", rc);
|
|
}
|
|
{
|
|
const int rc = InChild([] {
|
|
quitsignals::Install();
|
|
raise(SIGKILL);
|
|
return 0;
|
|
});
|
|
CHECK(rc == 128 + SIGKILL, "SIGKILL still kills (child: %d)", rc);
|
|
}
|
|
{
|
|
const int rc = InChild([] {
|
|
raise(SIGHUP);
|
|
return 0;
|
|
});
|
|
CHECK(rc == 128 + SIGHUP, "control: without the handlers SIGHUP kills, as in v0.1.5 (child: %d)", rc);
|
|
}
|
|
}
|
|
|
|
bool Exists(const fs::path& p)
|
|
{
|
|
std::error_code ec;
|
|
return fs::exists(p, ec);
|
|
}
|
|
|
|
const report::marker::Leftover* FindSession(const std::vector<report::marker::Leftover>& found, const std::string& session)
|
|
{
|
|
for (const auto& l : found)
|
|
if (l.session == session)
|
|
return &l;
|
|
return nullptr;
|
|
}
|
|
|
|
void Marker(const fs::path& scratch)
|
|
{
|
|
printf("the crash marker:\n");
|
|
namespace marker = report::marker;
|
|
std::error_code ec;
|
|
fs::remove_all(scratch, ec);
|
|
fs::create_directories(scratch, ec);
|
|
CHECK(!ec, "can't make %s", scratch.c_str());
|
|
|
|
const std::string clean = "2026-10-15_11-00-00", killed = "2026-10-15_11-10-00", crashed = "2026-10-15_11-20-00",
|
|
current = "2026-10-15_11-30-00";
|
|
auto arm = [&](const std::string& session) {
|
|
const std::string path = marker::PathFor(scratch, session).string();
|
|
return std::pair{ marker::Arm(path.c_str()), path };
|
|
};
|
|
{
|
|
auto [fd, path] = arm(clean);
|
|
CHECK(fd >= 0 && Exists(path) && fs::file_size(path, ec) == 0, "armed: an empty %s", path.c_str());
|
|
marker::Clear(fd, path.c_str());
|
|
CHECK(!Exists(path), "a clean end removes it");
|
|
}
|
|
{
|
|
auto [fd, path] = arm(killed);
|
|
close(fd); // the process gone without a word (SIGKILL)
|
|
}
|
|
{
|
|
auto [fd, path] = arm(crashed);
|
|
const char report[] = "SpeedBreaker (NFS: Most Wanted, Xbox 360): crash report\n";
|
|
CHECK(write(fd, report, sizeof(report) - 1) > 0, "write");
|
|
close(fd); // the handler's rename didn't happen
|
|
}
|
|
auto [currentFd, currentPath] = arm(current);
|
|
const std::vector<marker::Leftover> found = marker::Find(scratch, current);
|
|
CHECK(found.size() == 2, "the next launch finds 2 earlier markers (%zu)", found.size());
|
|
const marker::Leftover* k = FindSession(found, killed);
|
|
const marker::Leftover* c = FindSession(found, crashed);
|
|
CHECK(k && k->ended == marker::Ended::WithoutQuitting, "SIGKILL's empty marker: ended without quitting");
|
|
CHECK(c && c->ended == marker::Ended::Crashed, "a written marker: a crash report");
|
|
CHECK(!FindSession(found, clean), "the clean end left nothing");
|
|
CHECK(!FindSession(found, current), "the current session's own marker isn't an earlier one's");
|
|
marker::Clear(currentFd, currentPath.c_str());
|
|
|
|
// main()'s order, end to end: arm the marker, install the handlers,
|
|
// run frames until a quit is requested, then the quit path clears
|
|
// the marker and exits 0.
|
|
auto session = [&](const std::string& name) {
|
|
fflush(stdout);
|
|
const pid_t pid = fork();
|
|
if (pid == 0)
|
|
{
|
|
const std::string path = marker::PathFor(scratch, name).string();
|
|
const int fd = marker::Arm(path.c_str());
|
|
quitsignals::Install();
|
|
std::ofstream(scratch / (name + ".ready")) << "ready\n";
|
|
while (!quitsignals::Requested()) // RunFrame
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(2));
|
|
marker::Clear(fd, path.c_str()); // report::ClearCrashMarker
|
|
_exit(0);
|
|
}
|
|
CHECK(WaitFor([&] { return Exists(scratch / (name + ".ready")); }, 5000), "%s: the child started", name.c_str());
|
|
return pid;
|
|
};
|
|
for (int sig : quitsignals::kSignals)
|
|
{
|
|
const std::string name = std::string("signal-") + quitsignals::Name(sig);
|
|
const pid_t pid = session(name);
|
|
kill(pid, sig);
|
|
int status = 0;
|
|
waitpid(pid, &status, 0);
|
|
CHECK(WIFEXITED(status) && WEXITSTATUS(status) == 0, "%s: the session quit by itself, exit 0", quitsignals::Name(sig));
|
|
CHECK(!Exists(marker::PathFor(scratch, name)), "%s: no marker left", quitsignals::Name(sig));
|
|
}
|
|
{
|
|
const pid_t pid = session("signal-SIGKILL");
|
|
kill(pid, SIGKILL);
|
|
int status = 0;
|
|
waitpid(pid, &status, 0);
|
|
CHECK(WIFSIGNALED(status) && WTERMSIG(status) == SIGKILL, "SIGKILL: killed");
|
|
const std::vector<marker::Leftover> after = marker::Find(scratch, current);
|
|
const marker::Leftover* l = FindSession(after, "signal-SIGKILL");
|
|
CHECK(l && l->ended == marker::Ended::WithoutQuitting, "SIGKILL: its marker stays, and the next launch finds it ended without quitting");
|
|
}
|
|
fs::remove_all(scratch, ec);
|
|
}
|
|
|
|
std::string Read(const fs::path& path)
|
|
{
|
|
std::ifstream in(path);
|
|
std::stringstream ss;
|
|
ss << in.rdbuf();
|
|
return ss.str();
|
|
}
|
|
|
|
struct Sources
|
|
{
|
|
std::string main, presenter, crash;
|
|
};
|
|
|
|
// A function's body: from `head` to the brace at its own indent.
|
|
std::string Body(const std::string& src, const std::string& head)
|
|
{
|
|
const size_t at = src.find(head);
|
|
if (at == std::string::npos)
|
|
return {};
|
|
const size_t line = src.rfind('\n', at) + 1;
|
|
const std::string end = "\n" + src.substr(line, at - line) + "}";
|
|
const size_t stop = src.find(end, at);
|
|
return src.substr(at, stop == std::string::npos ? std::string::npos : stop - at);
|
|
}
|
|
|
|
// `a` before `b` in `text`, both there.
|
|
bool Before(const std::string& text, const std::string& a, const std::string& b, size_t from = 0)
|
|
{
|
|
const size_t x = text.find(a, from), y = text.find(b, from);
|
|
return x != std::string::npos && y != std::string::npos && x < y;
|
|
}
|
|
|
|
std::vector<std::string> SourceProblems(const Sources& s)
|
|
{
|
|
std::vector<std::string> problems;
|
|
// main.cpp: the handlers before SDL (the windowed start), and after
|
|
// the crash handlers' marker exists.
|
|
if (!Before(s.main, "report::PrepareCrashReports();", "quitsignals::Install();") ||
|
|
!Before(s.main, "quitsignals::Install();", "bool windowed = video::Initialize();"))
|
|
problems.push_back("main.cpp: quitsignals::Install() isn't between PrepareCrashReports and the window's video::Initialize");
|
|
// Any return from main() after the marker exists clears it.
|
|
const size_t prepared = s.main.find("report::PrepareCrashReports();");
|
|
const size_t guard = s.main.find("~ClearMarkerOnReturn() { report::ClearCrashMarker(); }");
|
|
const size_t firstReturnAfter = prepared == std::string::npos ? std::string::npos : s.main.find(" return ", prepared);
|
|
if (guard == std::string::npos || prepared == std::string::npos || guard < prepared || guard > firstReturnAfter)
|
|
problems.push_back("main.cpp: no marker-clearing guard between PrepareCrashReports and the first return after it");
|
|
// The window's quit path: the marker cleared after the shutdown, just
|
|
// before the log flush and the exit.
|
|
const size_t loop = s.main.find("while (video::RunFrame())");
|
|
if (loop == std::string::npos)
|
|
problems.push_back("main.cpp: no game loop");
|
|
else
|
|
{
|
|
const std::string tail = s.main.substr(loop, s.main.find("\n}", loop) - loop);
|
|
if (!Before(tail, "video::Shutdown();", "report::ClearCrashMarker();") ||
|
|
!Before(tail, "report::ClearCrashMarker();", "report::FlushLog();") ||
|
|
!Before(tail, "report::ClearCrashMarker();", "std::_Exit(0);") || tail.find("settings::Save()") == std::string::npos)
|
|
problems.push_back("main.cpp: the quit path doesn't save settings and clear the marker after the shutdown, before the exit");
|
|
}
|
|
// The launcher lifeline's exit and the headless one clear it too.
|
|
for (const char* head : { "launcher (pid %d) is gone: exiting", "quitting\\n\", quitsignals::Name(sig));" })
|
|
{
|
|
const size_t at = s.main.find(head);
|
|
if (at == std::string::npos || !Before(s.main, "report::ClearCrashMarker();", "std::_Exit(0);", at))
|
|
problems.push_back(std::string("main.cpp: the exit after \"") + head + "\" doesn't clear the marker first");
|
|
}
|
|
if (s.main.find("while (!s_guestReturned.load() && !quitsignals::Requested())") == std::string::npos)
|
|
problems.push_back("main.cpp: without a window, a quit request isn't waited for");
|
|
// presenter.cpp: RunFrame leaves on a request, before taking events.
|
|
const std::string run = Body(s.presenter, "bool RunFrame()");
|
|
const size_t request = run.find("if (quitsignals::Requested())");
|
|
if (request == std::string::npos || run.find("return false;", request) > run.find("while (SDL_PollEvent(&e))") ||
|
|
run.find("while (SDL_PollEvent(&e))") == std::string::npos)
|
|
problems.push_back("presenter.cpp: RunFrame doesn't leave on a quit request before its event loop");
|
|
// crash.cpp: a crash being reported keeps its report.
|
|
const std::string clear = Body(s.crash, "void ClearCrashMarker()");
|
|
if (!Before(clear, "if (s_crashing.load())", "marker::Clear("))
|
|
problems.push_back("crash.cpp: ClearCrashMarker doesn't leave a crash being reported alone");
|
|
const std::string notice = Body(s.crash, "void NoticePreviousCrash(bool lastClearsMarker)");
|
|
if (notice.find("marker::Ended::Crashed") == std::string::npos || notice.find("ended without quitting") == std::string::npos)
|
|
problems.push_back("crash.cpp: the next launch doesn't rename crash reports and log sessions that ended without quitting");
|
|
return problems;
|
|
}
|
|
|
|
std::string Replaced(std::string s, const std::string& from, const std::string& to)
|
|
{
|
|
const size_t at = s.find(from);
|
|
if (at == std::string::npos)
|
|
return {};
|
|
return s.replace(at, from.size(), to);
|
|
}
|
|
|
|
void Runtime(const fs::path& runtime)
|
|
{
|
|
printf("main.cpp, presenter.cpp, crash.cpp:\n");
|
|
const Sources src{ Read(runtime / "main.cpp"), Read(runtime / "video/presenter.cpp"), Read(runtime / "report/crash.cpp") };
|
|
CHECK(!src.main.empty() && !src.presenter.empty() && !src.crash.empty(), "can't read the sources in %s", runtime.c_str());
|
|
if (src.main.empty() || src.presenter.empty() || src.crash.empty())
|
|
return;
|
|
for (const std::string& p : SourceProblems(src))
|
|
CHECK(false, "%s", p.c_str());
|
|
struct Plant
|
|
{
|
|
const char* name;
|
|
std::string Sources::*file;
|
|
std::vector<std::pair<std::string, std::string>> edits; // each: the first `from` becomes `to`
|
|
};
|
|
const Plant plants[] = {
|
|
{ "v0.1.5's quit path (no marker cleared)", &Sources::main,
|
|
{ { " // A clean end: no crash marker left (a crash in the shutdown above still\n // reported one).\n"
|
|
" report::ClearCrashMarker();\n", "" } } },
|
|
{ "the marker cleared before the shutdown", &Sources::main,
|
|
{ { " gpu::renderer::Shutdown();\n video::Shutdown();\n",
|
|
" report::ClearCrashMarker();\n gpu::renderer::Shutdown();\n video::Shutdown();\n" } } },
|
|
{ "no handlers (SDL's, and SIGHUP's default)", &Sources::main, { { " quitsignals::Install();\n", "" } } },
|
|
{ "the handlers after SDL starts", &Sources::main,
|
|
{ { " quitsignals::Install();\n", "" },
|
|
{ " bool windowed = video::Initialize();\n", " bool windowed = video::Initialize();\n quitsignals::Install();\n" } } },
|
|
{ "no guard for returns", &Sources::main, { { "~ClearMarkerOnReturn() { report::ClearCrashMarker(); }", "~ClearMarkerOnReturn() {}" } } },
|
|
{ "the lifeline's exit keeps the marker", &Sources::main,
|
|
{ { " gpu::renderer::Shutdown();\n report::ClearCrashMarker();\n",
|
|
" gpu::renderer::Shutdown();\n" } } },
|
|
{ "headless: no wait for a request", &Sources::main,
|
|
{ { "while (!s_guestReturned.load() && !quitsignals::Requested())", "while (!s_guestReturned.load())" } } },
|
|
{ "RunFrame ignores the request", &Sources::presenter, { { " if (quitsignals::Requested())\n return false;\n", "" } } },
|
|
{ "a crash being reported cleared", &Sources::crash, { { " if (s_crashing.load())\n return;\n", "" } } },
|
|
};
|
|
for (const Plant& p : plants)
|
|
{
|
|
Sources mutated = src;
|
|
bool applied = true;
|
|
for (const auto& [from, to] : p.edits)
|
|
{
|
|
std::string next = Replaced(mutated.*p.file, from, to);
|
|
applied = applied && !next.empty();
|
|
if (!next.empty())
|
|
mutated.*p.file = next;
|
|
}
|
|
CHECK(applied, "plant \"%s\": its text isn't in the source", p.name);
|
|
if (!applied)
|
|
continue;
|
|
const bool caught = !SourceProblems(mutated).empty();
|
|
CHECK(caught, "plant \"%s\" not caught", p.name);
|
|
printf(" plant: %s: %s\n", p.name, caught ? "caught" : "NOT caught");
|
|
}
|
|
}
|
|
}
|
|
|
|
int main(int argc, char** argv)
|
|
{
|
|
Signals();
|
|
fs::path scratch = argc > 1 ? fs::path(argv[1]) : fs::temp_directory_path() / ("clean_exit_test." + std::to_string(getpid()));
|
|
Marker(scratch);
|
|
if (argc > 2)
|
|
Runtime(argv[2]);
|
|
else
|
|
printf("(the runtime folder not given: its checks skipped)\n");
|
|
printf("%s (%d failures)\n", g_failures ? "FAILED" : "passed", g_failures);
|
|
return g_failures ? 1 : 0;
|
|
}
|