mirror of
https://github.com/libretro/virtualjaguar-libretro.git
synced 2026-11-07 12:47:58 -10:00
The app never answers the halt pump with DETACH any more (#1069). Inside GDBHalt() a dropped host let a gdb client's still-armed Z0 park the core again with nobody to pump: threaded, the mirror read "not halted" and every frame-lane call waited for gdb; inline, the window froze. Detach, Open, Close, Reset, a disc change and the hardcore flip all reached that. One rule for leaving a halt (docs/app-debug-tools.md): every release is a CONTINUE with the host kept, decided by one lane post on the emulation thread with the core's real state. Detach disarms and drops the app's breakpoint list and defers the host drop to DebugCore::frameEnded(), outside retro_run(); a new halt before then cancels the drop (that halt needs the host) and an attach() in that window supersedes it. Reset, Open, Close and the disc commands (App::releaseForCall / callReleased) disarm the enabled breakpoints for the release and re-arm them before the first frame after the command, so the list survives a Reset (#1081 M1). The give-up predicate of the call that follows fires only on a halt newer than the released one (DebugCore::releaseSerial), which also covers a Detach made while a step is in flight (M5). Also from the Phase 3 review (#1081): the SRAM-autosave and achievements idle posts are coalesced to one in flight so a long halt leaves the frame lane bounded (M2; rc_client_idle still does not run while halted, on purpose), halt_reason() is read before haltMu_ (M4), the same-pc fold is documented in the pump (M3), nothing is refused while a Detach completes (M6), and systemRam() carries its owner-thread note (M7). Tests: debug_e2e_threaded sections 13 (threaded: Detach then Reset while a gdb client holds a Z0 halt in the hot loop; an app breakpoint survives a Reset) and 14 (the same Detach inline) fail on the old code and pass now; debug_session_unit pins the rule against the mock (frame-end drop, the cancel on a new halt, release + re-arm, the release serial, Detach then Break). Part of issue 967. Co-authored-by: Claude Opus 5.5 <[email protected]>
732 lines
34 KiB
C++
732 lines
34 KiB
C++
// The debugger halt on the emulation thread (#967 phase 3b), with the REAL core. This (test) thread plays the UI: it
|
|
// issues what the Tools windows issue, and polls the way App::syncDebug polls (syncCore + syncHalt, then look). Every
|
|
// UI-side operation is timed: none may block over 50 ms, because the window keeps drawing while the game is halted.
|
|
//
|
|
// The rig mirrors App (wireHost + startEmuThread + emuRunOneFrame's tail + haltWait): the policy under test is App's, not a
|
|
// test-only one. Sections:
|
|
// 1 break / step / continue 2 a debug call while halted 3 a frame-lane option post waits for Continue
|
|
// 4 a save state that gives up when a halt begins 5 detach-first before a content call
|
|
// 6 hardcore from the UI while halted 7 a halt during a debugCall from the UI
|
|
// 8 a posted lane detach inside a live halt 9 stop() while halted, abortHalt alone
|
|
// then, each on a fresh rig: a gdb_wait boot halt with no client (10: Detach releases it; 11: stop() joins within 500 ms),
|
|
// and 12: a gdb CLIENT holds a halt raised by a persistent breakpoint while the app quits (the quit epilogue's flush, unload
|
|
// and stop() must all complete: a core that only dropped the app's host would re-hit the breakpoint and park behind the
|
|
// open socket for good). 13 (#1069): the same held breakpoint, and the app does Detach, then a Reset: the re-hit must stay
|
|
// visible to the app (the host is never dropped from inside a halt) and the Reset call must return, not wait on a core
|
|
// nobody pumps; then an app breakpoint survives a Reset (#1081 M1). 14: the #1069 Detach INLINE (EmuThread::Mode::Inline,
|
|
// the pump runs the UI frame): the UI frame must run again after the Detach.
|
|
//
|
|
// Sections 13 and 14 wait only in bounded polls (until(): 2 ms rounds, 5 s worth; the client's own round count) and never
|
|
// assert that something took under N ms: a hosted CI VM is slow, and the unfixed app does not finish, it hangs.
|
|
//
|
|
// usage: vj_debug_e2e_threaded <rom.j64>
|
|
#include "core_host.h"
|
|
#include "debug/debug_session.h"
|
|
#include "emu_thread.h"
|
|
|
|
#include <atomic>
|
|
#include <chrono>
|
|
#include <cstdio>
|
|
#include <ctime>
|
|
#include <filesystem>
|
|
#include <functional>
|
|
#include <string>
|
|
#include <thread>
|
|
#include <vector>
|
|
|
|
#ifdef _WIN32
|
|
#include <winsock2.h>
|
|
#include <ws2tcpip.h> // socklen_t (MSVC defines it here, not in winsock2.h)
|
|
#else
|
|
#include <arpa/inet.h>
|
|
#include <netinet/in.h>
|
|
#include <sys/socket.h>
|
|
#include <unistd.h>
|
|
#endif
|
|
|
|
using namespace vj;
|
|
namespace fs = std::filesystem;
|
|
|
|
static int g_fail = 0, g_checks = 0;
|
|
#define CHECK(cond) \
|
|
do { \
|
|
g_checks++; \
|
|
if (!(cond)) { \
|
|
g_fail++; \
|
|
std::fprintf(stderr, "FAIL %s:%d %s\n", __FILE__, __LINE__, #cond); \
|
|
} \
|
|
} while (0)
|
|
|
|
namespace {
|
|
|
|
using Clock = std::chrono::steady_clock;
|
|
double since(Clock::time_point t0) { return std::chrono::duration<double>(Clock::now() - t0).count(); }
|
|
void sleepMs(int ms) { std::this_thread::sleep_for(std::chrono::milliseconds(ms)); }
|
|
|
|
struct Rig : EmuThread::Client {
|
|
CoreHost& host = CoreHost::instance();
|
|
EmuThread emu;
|
|
DebugSession dbg;
|
|
std::atomic<bool> quitting{false};
|
|
std::atomic<int> haltsEmu{0}, resumesEmu{0};
|
|
void threadStarted() override { host.setOwner(std::this_thread::get_id()); }
|
|
void threadStopping() override { host.setOwner(std::thread::id()); }
|
|
bool canRun() override { return host.isLoaded(); }
|
|
double framePeriod() override { return 1.0 / 60.0; }
|
|
double audioQueuedMs() override { return -1.0; }
|
|
void runFrame() override { // App::emuRunOneFrame's tail
|
|
host.runFrame();
|
|
dbg.core().frameEnded();
|
|
host.publishSnapshot();
|
|
dbg.fillViews();
|
|
}
|
|
void afterCommands() override { host.publishSnapshot(); }
|
|
void wire() { // what App::wireHost + startEmuThread do
|
|
host.onDebugIface = [this](const vj_debug_iface* i) { dbg.onCoreIface(i); };
|
|
host.post = [this](std::function<void()> fn) { emu.post(std::move(fn)); };
|
|
dbg.coreCall = [this](std::function<void()> fn) { emu.debugCall([&] { fn(); }); };
|
|
dbg.corePost = [this](std::function<void()> fn) { emu.debugPost(std::move(fn)); };
|
|
dbg.core().notifyVerdict = [this] { emu.debugPost([] {}); };
|
|
dbg.core().waitVerdict = [this] { // App::haltWait
|
|
emu.serveDebugLane(DebugCore::kPumpWait);
|
|
if (dbg.views().pending(dbg.refreshSerial())) dbg.fillViews();
|
|
};
|
|
dbg.core().abortHalt = [this] { return quitting.load(); };
|
|
dbg.core().onHalt.push_back([this](int, unsigned, int) { haltsEmu++; });
|
|
dbg.core().onResume.push_back([this] { resumesEmu++; });
|
|
}
|
|
void unwire() {
|
|
host.onDebugIface = nullptr;
|
|
host.post = nullptr;
|
|
}
|
|
void sync() { // App::syncDebug's first lines, once per UI frame
|
|
dbg.syncCore();
|
|
dbg.syncHalt();
|
|
}
|
|
// App::openContentOnCore: the load and the silent gdb host's attach are one emulation-thread task, so the boot halt
|
|
// (gdb_wait) that the first frame raises finds the host.
|
|
bool loadAttached(const std::string& rom, std::string& err) {
|
|
const bool ok = emu.call([&] {
|
|
const bool l = host.loadContent(rom, err);
|
|
if (l && dbg.core().loaded() && !dbg.core().attached()) dbg.core().attach();
|
|
return l;
|
|
});
|
|
sync();
|
|
dbg.setContentLoaded(ok);
|
|
return ok;
|
|
}
|
|
bool load(const std::string& rom, std::string& err) {
|
|
const bool ok = emu.call([&] { return host.loadContent(rom, err); });
|
|
sync();
|
|
dbg.setContentLoaded(ok);
|
|
return ok;
|
|
}
|
|
};
|
|
|
|
// A free loopback TCP port (bind to 0, read it back, close), so parallel ctest runs do not collide on the gdb stub's port.
|
|
// Fallback when that fails: a pid-derived port.
|
|
std::string freePort() {
|
|
int port = 0;
|
|
#ifdef _WIN32
|
|
WSADATA wsa;
|
|
WSAStartup(MAKEWORD(2, 2), &wsa);
|
|
SOCKET fd = socket(AF_INET, SOCK_STREAM, 0);
|
|
const bool bad = fd == INVALID_SOCKET;
|
|
#else
|
|
int fd = socket(AF_INET, SOCK_STREAM, 0);
|
|
const bool bad = fd < 0;
|
|
#endif
|
|
if (!bad) {
|
|
sockaddr_in a{};
|
|
a.sin_family = AF_INET;
|
|
a.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
|
a.sin_port = 0;
|
|
socklen_t n = sizeof a;
|
|
if (bind(fd, (sockaddr*)&a, sizeof a) == 0 && getsockname(fd, (sockaddr*)&a, &n) == 0) port = ntohs(a.sin_port);
|
|
#ifdef _WIN32
|
|
closesocket(fd);
|
|
#else
|
|
close(fd);
|
|
#endif
|
|
}
|
|
if (port <= 0) port = 40000 + (int)(std::clock() % 20000);
|
|
return std::to_string(port);
|
|
}
|
|
|
|
// Runs `fn` on this thread and checks it took under 50 ms.
|
|
template <class F>
|
|
void ui(const char* what, F fn) {
|
|
const auto t0 = Clock::now();
|
|
fn();
|
|
const double s = since(t0);
|
|
if (s > 0.05) {
|
|
g_fail++;
|
|
std::fprintf(stderr, "FAIL %s blocked the UI thread for %.0f ms\n", what, s * 1e3);
|
|
}
|
|
}
|
|
|
|
// The UI's poll: sync, test, 2 ms, up to 5 s.
|
|
template <class F>
|
|
bool until(Rig& r, F cond) {
|
|
const auto end = Clock::now() + std::chrono::seconds(5);
|
|
for (;;) {
|
|
r.sync();
|
|
if (cond()) return true;
|
|
if (Clock::now() >= end) return false;
|
|
sleepMs(2);
|
|
}
|
|
}
|
|
|
|
// A minimal gdb-remote client (blocking loopback socket + a reader thread that drains what the stub sends, so the stub never
|
|
// meets a full socket): send() frames "$payload#cs". The stub's replies are not parsed here: the halts are observed
|
|
// through the app's own mirror.
|
|
struct GdbClient {
|
|
#ifdef _WIN32
|
|
SOCKET fd = INVALID_SOCKET;
|
|
#else
|
|
int fd = -1;
|
|
#endif
|
|
std::thread reader;
|
|
bool open() const {
|
|
#ifdef _WIN32
|
|
return fd != INVALID_SOCKET;
|
|
#else
|
|
return fd >= 0;
|
|
#endif
|
|
}
|
|
bool connectTo(const std::string& port) {
|
|
for (int i = 0; i < 250 && !open(); i++) {
|
|
fd = (decltype(fd))socket(AF_INET, SOCK_STREAM, 0);
|
|
sockaddr_in a{};
|
|
a.sin_family = AF_INET;
|
|
a.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
|
a.sin_port = htons((unsigned short)std::stoi(port));
|
|
if (connect(fd, (sockaddr*)&a, sizeof a) == 0) break;
|
|
closeFd();
|
|
sleepMs(20);
|
|
}
|
|
if (!open()) return false;
|
|
reader = std::thread([this] {
|
|
char buf[4096];
|
|
while (recv(fd, buf, sizeof buf, 0) > 0) {}
|
|
});
|
|
return true;
|
|
}
|
|
void send(const std::string& payload) {
|
|
unsigned sum = 0;
|
|
for (unsigned char c : payload) sum += c;
|
|
char tail[8];
|
|
std::snprintf(tail, sizeof tail, "#%02x", sum & 0xFF);
|
|
const std::string pkt = "$" + payload + tail;
|
|
(void)::send(fd, pkt.data(), (int)pkt.size(), 0);
|
|
}
|
|
void closeFd() {
|
|
if (!open()) return;
|
|
#ifdef _WIN32
|
|
closesocket(fd);
|
|
fd = INVALID_SOCKET;
|
|
#else
|
|
::close(fd);
|
|
fd = -1;
|
|
#endif
|
|
}
|
|
void close() {
|
|
if (open()) {
|
|
#ifdef _WIN32
|
|
shutdown(fd, SD_BOTH);
|
|
#else
|
|
shutdown(fd, SHUT_RDWR);
|
|
#endif
|
|
}
|
|
if (reader.joinable()) reader.join();
|
|
closeFd();
|
|
}
|
|
};
|
|
|
|
} // namespace
|
|
|
|
int main(int argc, char** argv) {
|
|
if (argc < 2) {
|
|
std::fprintf(stderr, "usage: vj_debug_e2e_threaded <rom.j64>\n");
|
|
return 2;
|
|
}
|
|
const std::string rom = argv[1];
|
|
std::error_code ec;
|
|
fs::path tmp = fs::temp_directory_path(ec);
|
|
if (ec) tmp = ".";
|
|
tmp /= "vj-debug-e2e-thr-" + std::to_string((long long)std::time(nullptr)) + "-" + std::to_string((long long)clock());
|
|
fs::create_directories(tmp / "system", ec);
|
|
fs::create_directories(tmp / "saves", ec);
|
|
const std::string t = tmp.string() + "/";
|
|
|
|
Rig r;
|
|
r.host.setDirectories(t + "system/", t + "saves/");
|
|
r.wire();
|
|
CHECK(r.emu.start(EmuThread::Mode::Threaded, r));
|
|
std::string err;
|
|
CHECK(r.load(rom, err));
|
|
CHECK(until(r, [&] { return r.host.frameCount() >= 30; }));
|
|
const std::string stateFile = t + "s4.state";
|
|
|
|
// 1. Break / step / continue from the UI thread.
|
|
ui("breakNow", [&] { r.dbg.breakNow(); });
|
|
CHECK(until(r, [&] { return r.dbg.halted(); }));
|
|
CHECK(r.dbg.haltReason() == VJ_DBG_STOP_USER && !r.dbg.haltByGdb());
|
|
CHECK(until(r, [&] { return r.haltsEmu == 1; })); // the core publishes the halt, THEN runs onHalt: poll the counter
|
|
const uint64_t f = r.host.frameCount();
|
|
sleepMs(100);
|
|
CHECK(r.host.frameCount() == f); // no frames while halted
|
|
const unsigned pc1 = r.dbg.haltedPc();
|
|
const uint64_t s1 = r.dbg.haltSerial();
|
|
ui("step", [&] { r.dbg.step(); });
|
|
CHECK(until(r, [&] { return r.dbg.haltSerial() != s1 && r.dbg.halted(); }));
|
|
CHECK(r.dbg.haltReason() == VJ_DBG_STOP_STEP && !r.dbg.haltByGdb() && r.resumesEmu == 0); // pc may equal pc1: not asserted
|
|
ui("resume", [&] { r.dbg.resume(); });
|
|
CHECK(until(r, [&] { return !r.dbg.halted(); }));
|
|
CHECK(until(r, [&] { return r.resumesEmu == 1; })); // likewise onResume runs just after the halt closes
|
|
CHECK(until(r, [&] { return r.host.frameCount() > f; }));
|
|
|
|
// 2. A debug call while halted answers within 50 ms (the pump serves the lane).
|
|
ui("breakNow", [&] { r.dbg.breakNow(); });
|
|
CHECK(until(r, [&] { return r.dbg.halted(); }));
|
|
ui("read", [&] {
|
|
std::vector<uint8_t> b;
|
|
CHECK(r.dbg.read(0x7000, b, 4));
|
|
});
|
|
ui("poke", [&] { CHECK(r.dbg.pokeU(0x7000, 4, 0xCAFEF00D) == 4); });
|
|
vj_debug_regs regs{};
|
|
ui("regs", [&] { CHECK(r.dbg.getRegsNow(VJ_DBG_TARGET_M68K, regs)); });
|
|
CHECK(regs.pc == r.dbg.haltedPc());
|
|
unsigned v = 0;
|
|
ui("readU", [&] { CHECK(r.dbg.readU(0x7000, 4, v) && v == 0xCAFEF00D); });
|
|
|
|
// 3. Review Focus 5: a frame-lane option post made while halted applies only after Continue. The UI-side getter
|
|
// (uiUser_) answers the new value at once by design, so the core's own copy is read where it lives: on the emulation
|
|
// thread, through the debug lane (the pump serves it while halted).
|
|
const auto coreOption = [&] {
|
|
std::string val;
|
|
r.dbg.callCore([&] { val = r.host.optionValueFor("virtualjaguar_blitter_timing", "unset"); });
|
|
return val;
|
|
};
|
|
CHECK(coreOption() == "unset");
|
|
r.host.setOptionValue("virtualjaguar_blitter_timing", "enabled");
|
|
sleepMs(60);
|
|
CHECK(coreOption() == "unset"); // the post waits for a frame boundary; the halted frame has none
|
|
const uint64_t f3 = r.host.frameCount();
|
|
ui("resume", [&] { r.dbg.resume(); });
|
|
CHECK(until(r, [&] { return !r.dbg.halted(); })); // the mirror must see the resume before breakNow() is allowed again
|
|
CHECK(until(r, [&] { return coreOption() == "enabled"; }));
|
|
CHECK(r.host.frameCount() >= f3 + 1);
|
|
r.host.resetOptionToDefault("virtualjaguar_blitter_timing");
|
|
|
|
// 4. Review Focus 1: a save state requested just before a halt returns false within 300 ms, and the cancelled save never runs.
|
|
ui("breakNow", [&] { r.dbg.breakNow(); });
|
|
CHECK(until(r, [&] { return r.dbg.halted(); }));
|
|
const uint64_t s4 = r.dbg.haltSerial();
|
|
r.dbg.callCore([&] { r.dbg.core().haltNext(VJ_DBG_TARGET_M68K); }); // served by the pump: the next instruction after Continue re-halts
|
|
std::atomic<bool> saved{false};
|
|
double tCall = 0;
|
|
std::string err4;
|
|
std::thread caller([&] {
|
|
const auto t0 = Clock::now();
|
|
saved = r.emu.call([&] { return r.host.saveStateToFile(stateFile, err4); },
|
|
[&] {
|
|
const HaltState h = r.dbg.core().halt();
|
|
return h.halted && h.serial != s4;
|
|
});
|
|
tCall = since(t0);
|
|
});
|
|
sleepMs(5);
|
|
ui("resume", [&] { r.dbg.resume(); });
|
|
caller.join();
|
|
CHECK(!saved && err4.empty() && tCall < 0.3);
|
|
CHECK(until(r, [&] { return r.dbg.halted() && r.dbg.haltSerial() != s4; }));
|
|
CHECK(r.dbg.haltReason() == VJ_DBG_STOP_USER);
|
|
ui("resume", [&] { r.dbg.resume(); });
|
|
CHECK(until(r, [&] { return !r.dbg.halted(); }));
|
|
// The cancelled task must never have run: a frame-lane task is held while the core is halted, so the proof is after the
|
|
// halt ended and frames ran (a stale task would have been drained and written the file by now).
|
|
const uint64_t f4 = r.host.frameCount();
|
|
CHECK(until(r, [&] { return r.host.frameCount() > f4 + 3; }));
|
|
CHECK(!fs::exists(stateFile) && err4.empty());
|
|
const std::string stateFile2 = t + "s4b.state"; // a different file: the final save is its own proof
|
|
CHECK(r.emu.call([&] { return r.host.saveStateToFile(stateFile2, err4); }, [&] { return r.dbg.core().halt().halted; }) &&
|
|
fs::exists(stateFile2)); // no halt now: the save runs
|
|
CHECK(!fs::exists(stateFile));
|
|
|
|
// 5. Release-first: a content call after releaseHalt() (App::releaseForCall) runs within one frame and nothing re-halts
|
|
// (the breakpoint at pc1 -- revisited every frame -- is disarmed for the release). The predicate is App's.
|
|
CHECK(r.dbg.addBreakpoint(VJ_DBG_TARGET_M68K, pc1));
|
|
CHECK(until(r, [&] { return r.dbg.halted(); })); // pc1 is revisited: the inline e2e relies on the same
|
|
CHECK(r.dbg.haltReason() == VJ_DBG_STOP_BREAKPOINT);
|
|
const uint64_t s5 = r.dbg.haltSerial();
|
|
ui("release", [&] { r.dbg.releaseHalt(); });
|
|
const auto t5 = Clock::now();
|
|
CHECK(r.emu.call([&] { return r.host.loadContent(rom, err); },
|
|
[&] {
|
|
const uint64_t rel = r.dbg.core().releaseSerial();
|
|
const HaltState h = r.dbg.core().halt();
|
|
return rel >= s5 && h.halted && h.serial > rel;
|
|
}));
|
|
r.dbg.rearmBreakpoints(); // what App does after the call: a no-op here, the core was swapped
|
|
CHECK(since(t5) < 0.5);
|
|
r.sync();
|
|
CHECK(!r.dbg.halted() && !r.dbg.attached() && r.dbg.breakpoints().empty()); // syncCore forgets the list on the core swap
|
|
CHECK(until(r, [&] { return r.host.frameCount() >= 10; }));
|
|
|
|
// 6. Hardcore from the UI while halted.
|
|
ui("breakNow", [&] { r.dbg.breakNow(); });
|
|
CHECK(until(r, [&] { return r.dbg.halted(); }));
|
|
std::string closed;
|
|
r.dbg.onForcedClose.push_back([&](const char* w) { closed = w; });
|
|
ui("hardcore", [&] { r.dbg.setHardcore(true); });
|
|
CHECK(until(r, [&] { return !r.dbg.halted(); }));
|
|
CHECK(!r.dbg.attached() && closed == "hardcore");
|
|
const uint64_t f6 = r.host.frameCount();
|
|
CHECK(until(r, [&] { return r.host.frameCount() > f6 + 5; }));
|
|
r.dbg.setHardcore(false);
|
|
CHECK(r.dbg.enabled());
|
|
|
|
// 7. A halt during a debugCall issued from the UI.
|
|
ui("breakNow+read", [&] {
|
|
r.dbg.breakNow();
|
|
std::vector<uint8_t> b;
|
|
CHECK(r.dbg.read(0, b, 16));
|
|
});
|
|
CHECK(until(r, [&] { return r.dbg.halted(); }));
|
|
ui("resume", [&] { r.dbg.resume(); });
|
|
CHECK(until(r, [&] { return !r.dbg.halted(); }));
|
|
|
|
// 8. A posted lane detach that runs inside a live halt (the B1 carry): the UI's mirror has not seen the halt yet; the
|
|
// pump serves the post, which answers CONTINUE for the halt it finds (never DETACH: #1069) and leaves the host for the
|
|
// frame end. Nothing below calls sync() until the core is halted, which is what keeps the mirror stale.
|
|
ui("breakNow", [&] { r.dbg.breakNow(); });
|
|
{
|
|
const auto end = Clock::now() + std::chrono::seconds(5);
|
|
while (!r.dbg.core().halt().halted && Clock::now() < end) sleepMs(1);
|
|
}
|
|
CHECK(r.dbg.core().halt().halted);
|
|
CHECK(!r.dbg.halted()); // the mirror lags: that is the window
|
|
const uint64_t r8 = (uint64_t)r.resumesEmu.load();
|
|
ui("detach (stale mirror)", [&] { r.dbg.detach(); });
|
|
CHECK(until(r, [&] { return !r.dbg.core().halt().halted; })); // the pump released
|
|
CHECK(until(r, [&] { return !r.dbg.halted() && !r.dbg.attached(); }));
|
|
CHECK(until(r, [&] { return (uint64_t)r.resumesEmu.load() == r8 + 1; }));
|
|
const uint64_t f8 = r.host.frameCount();
|
|
CHECK(until(r, [&] { return r.host.frameCount() > f8 + 3; })); // the game runs on, nothing re-halts
|
|
CHECK(!r.dbg.core().halt().halted);
|
|
CHECK(until(r, [&] { return !r.dbg.core().attached() && !r.dbg.core().detachPending(); })); // dropped at the frame end
|
|
|
|
// The main rig ends here (not halted); sections 9-11 each use a fresh rig and thread.
|
|
CHECK(r.emu.callFor(std::chrono::milliseconds(3000), [&] { r.host.unloadContent(); }));
|
|
CHECK(r.emu.stop());
|
|
r.unwire();
|
|
|
|
// 9. Review Focus 6: stop() returns true within 500 ms while halted, with abortHalt alone (a user halt).
|
|
{
|
|
Rig q;
|
|
q.host.setDirectories(t + "system/", t + "saves/");
|
|
q.wire();
|
|
CHECK(q.emu.start(EmuThread::Mode::Threaded, q));
|
|
CHECK(q.load(rom, err));
|
|
CHECK(until(q, [&] { return q.host.frameCount() >= 10; }));
|
|
ui("breakNow", [&] { q.dbg.breakNow(); });
|
|
CHECK(until(q, [&] { return q.dbg.halted(); }));
|
|
q.quitting = true;
|
|
q.emu.debugPost([] {});
|
|
const auto t9 = Clock::now();
|
|
CHECK(q.emu.callFor(std::chrono::milliseconds(3000), [&] { q.host.unloadContent(); }));
|
|
CHECK(q.emu.stop());
|
|
CHECK(since(t9) < 0.5);
|
|
CHECK(!q.dbg.core().attached());
|
|
q.unwire();
|
|
}
|
|
|
|
// 10. A threaded gdb_wait boot halt with no client: the silent host sees it (by gdb), the UI mirror shows it, no frame
|
|
// runs, and Detach releases it -- the game then runs.
|
|
{
|
|
Rig q;
|
|
q.host.setDirectories(t + "system/", t + "saves/");
|
|
q.host.setOptionValue("virtualjaguar_gdb_stub", "enabled");
|
|
q.host.setOptionValue("virtualjaguar_gdb_wait", "enabled");
|
|
q.host.setOptionValue("virtualjaguar_gdb_port", freePort());
|
|
q.wire();
|
|
CHECK(q.emu.start(EmuThread::Mode::Threaded, q));
|
|
CHECK(q.loadAttached(rom, err));
|
|
CHECK(until(q, [&] { return q.dbg.halted(); }));
|
|
CHECK(q.dbg.haltByGdb());
|
|
CHECK(until(q, [&] { return q.haltsEmu == 1; }));
|
|
const uint64_t fb = q.host.frameCount();
|
|
sleepMs(100);
|
|
CHECK(q.host.frameCount() == fb && q.dbg.halted()); // parked until someone resumes it
|
|
ui("detach", [&] { q.dbg.detach(); });
|
|
CHECK(until(q, [&] { return !q.dbg.halted(); }));
|
|
CHECK(until(q, [&] { return q.host.frameCount() > fb + 5; }));
|
|
CHECK(!q.dbg.core().halt().halted);
|
|
CHECK(until(q, [&] { return !q.dbg.core().attached(); })); // the host went at the frame end (no client: no re-halt)
|
|
q.quitting = true;
|
|
CHECK(q.emu.callFor(std::chrono::milliseconds(3000), [&] { q.host.unloadContent(); }));
|
|
CHECK(q.emu.stop());
|
|
q.unwire();
|
|
q.host.resetOptionToDefault("virtualjaguar_gdb_stub");
|
|
q.host.resetOptionToDefault("virtualjaguar_gdb_wait");
|
|
q.host.resetOptionToDefault("virtualjaguar_gdb_port");
|
|
}
|
|
|
|
// 11. The same boot halt, then quit: stop() joins within 500 ms (abortHalt alone), nothing left attached.
|
|
{
|
|
Rig q;
|
|
q.host.setDirectories(t + "system/", t + "saves/");
|
|
q.host.setOptionValue("virtualjaguar_gdb_stub", "enabled");
|
|
q.host.setOptionValue("virtualjaguar_gdb_wait", "enabled");
|
|
q.host.setOptionValue("virtualjaguar_gdb_port", freePort());
|
|
q.wire();
|
|
CHECK(q.emu.start(EmuThread::Mode::Threaded, q));
|
|
CHECK(q.loadAttached(rom, err));
|
|
CHECK(until(q, [&] { return q.dbg.halted(); }));
|
|
q.quitting = true;
|
|
q.emu.debugPost([] {});
|
|
const auto t11 = Clock::now();
|
|
CHECK(q.emu.callFor(std::chrono::milliseconds(3000), [&] { q.host.unloadContent(); }));
|
|
CHECK(q.emu.stop());
|
|
CHECK(since(t11) < 0.5);
|
|
CHECK(!q.dbg.core().attached());
|
|
q.unwire();
|
|
q.host.resetOptionToDefault("virtualjaguar_gdb_stub");
|
|
q.host.resetOptionToDefault("virtualjaguar_gdb_wait");
|
|
q.host.resetOptionToDefault("virtualjaguar_gdb_port");
|
|
}
|
|
|
|
// 12. A gdb CLIENT holds a halt raised by a PERSISTENT breakpoint (a hot loop: it fires again and again) while the app
|
|
// quits. The quit answers the pump with CONTINUE and keeps the host attached; a core that only dropped the host would
|
|
// hit the armed breakpoint again at once and park behind the open socket, and the epilogue's flush, unload and stop()
|
|
// would all wait on it. The sequence is runInteractive's: beginQuit (quitting + wake + bounded poll), then a bounded
|
|
// flush, the unload and the join.
|
|
{
|
|
Rig q;
|
|
q.host.setDirectories(t + "system/", t + "saves/");
|
|
const std::string port = freePort();
|
|
q.host.setOptionValue("virtualjaguar_gdb_stub", "enabled");
|
|
q.host.setOptionValue("virtualjaguar_gdb_wait", "enabled");
|
|
q.host.setOptionValue("virtualjaguar_gdb_port", port);
|
|
q.wire();
|
|
CHECK(q.emu.start(EmuThread::Mode::Threaded, q));
|
|
CHECK(q.loadAttached(rom, err));
|
|
CHECK(until(q, [&] { return q.dbg.halted(); })); // the gdb_wait boot halt
|
|
GdbClient gc;
|
|
CHECK(gc.connectTo(port));
|
|
gc.send("qSupported:multiprocess+");
|
|
sleepMs(50);
|
|
gc.send("Z0,8021c4,2");
|
|
sleepMs(50);
|
|
gc.send("c");
|
|
CHECK(until(q, [&] { return q.dbg.halted() && q.dbg.haltedPc() == 0x8021C4u; }));
|
|
CHECK(q.dbg.haltByGdb());
|
|
sleepMs(200);
|
|
CHECK(q.dbg.halted()); // held: nobody continues
|
|
// beginQuit
|
|
const auto t12 = Clock::now();
|
|
q.quitting = true;
|
|
q.emu.debugPost([] {});
|
|
for (int i = 0; i < 100 && q.dbg.core().halt().halted; i++) sleepMs(2);
|
|
// the epilogue: each wait bounded
|
|
CHECK(q.emu.callFor(std::chrono::milliseconds(1500), [&] { q.host.flushSaveRam(true); }));
|
|
CHECK(q.dbg.core().attached()); // the host was never dropped out from under the parked core
|
|
CHECK(q.emu.callFor(std::chrono::milliseconds(3000), [&] { q.host.unloadContent(); }));
|
|
CHECK(q.emu.stop(std::chrono::milliseconds(1500)));
|
|
CHECK(since(t12) < 2.0);
|
|
gc.close();
|
|
q.unwire();
|
|
q.host.resetOptionToDefault("virtualjaguar_gdb_stub");
|
|
q.host.resetOptionToDefault("virtualjaguar_gdb_wait");
|
|
q.host.resetOptionToDefault("virtualjaguar_gdb_port");
|
|
}
|
|
|
|
// 13. #1069: a gdb CLIENT holds a halt at its own persistent breakpoint (the hot loop again) and the user presses Detach,
|
|
// then Reset. The Detach must not drop the app's host from inside the halt: the core would hit the still-armed
|
|
// breakpoint again at once and park behind the open socket with nobody to pump, the mirror would read "not halted" and
|
|
// every frame-lane call (the Reset) would wait until gdb continues. Required: the re-hit shows up in the mirror as a
|
|
// new halt by gdb, the Reset call returns (it gives up: gdb holds the game), and once gdb removes its breakpoint and
|
|
// continues, the game runs on with the host still attached and a Reset runs. Then #1081 M1: the app's own breakpoint
|
|
// survives a Reset -- the halt is released for the call and the list is re-armed before the first frame after it.
|
|
{
|
|
Rig q;
|
|
q.host.setDirectories(t + "system/", t + "saves/");
|
|
const std::string port = freePort();
|
|
q.host.setOptionValue("virtualjaguar_gdb_stub", "enabled");
|
|
q.host.setOptionValue("virtualjaguar_gdb_wait", "enabled");
|
|
q.host.setOptionValue("virtualjaguar_gdb_port", port);
|
|
q.wire();
|
|
CHECK(q.emu.start(EmuThread::Mode::Threaded, q));
|
|
CHECK(q.loadAttached(rom, err));
|
|
CHECK(until(q, [&] { return q.dbg.halted(); })); // the gdb_wait boot halt
|
|
GdbClient gc;
|
|
CHECK(gc.connectTo(port));
|
|
gc.send("qSupported:multiprocess+");
|
|
sleepMs(50);
|
|
gc.send("Z0,8021c4,2");
|
|
sleepMs(50);
|
|
gc.send("c");
|
|
CHECK(until(q, [&] { return q.dbg.halted() && q.dbg.haltedPc() == 0x8021C4u; }));
|
|
CHECK(q.dbg.haltByGdb());
|
|
const uint64_t s13 = q.dbg.haltSerial();
|
|
// Detach while gdb holds the halt (what the halt bar's Detach does).
|
|
ui("detach", [&] { q.dbg.detach(); });
|
|
// The core continues and hits gdb's breakpoint again at once: the app must see THAT halt (a host-less park never
|
|
// reaches the mirror, and this poll runs out).
|
|
CHECK(until(q, [&] { return q.dbg.halted() && q.dbg.haltSerial() != s13 && q.dbg.haltByGdb(); }));
|
|
CHECK(q.dbg.breakpoints().empty());
|
|
CHECK(q.dbg.core().attached()); // the host stayed: it is what makes the halt visible
|
|
// Reset after the Detach (App::reset = App::callReleased: releaseHalt, then a frame-lane call with App::releaseForCall's
|
|
// predicate). It must return: gdb holds the game, so it gives up and never runs -- a UI that waits here is the bug.
|
|
const uint64_t s13b = q.dbg.haltSerial();
|
|
ui("release", [&] { q.dbg.releaseHalt(); });
|
|
std::atomic<bool> returned{false}, ran13{false};
|
|
std::thread caller([&] {
|
|
q.emu.call(
|
|
[&] {
|
|
ran13 = true;
|
|
q.host.reset();
|
|
q.dbg.rearmBreakpoints();
|
|
},
|
|
[&] {
|
|
const uint64_t rel = q.dbg.core().releaseSerial();
|
|
const HaltState h = q.dbg.core().halt();
|
|
return rel >= s13b && h.halted && h.serial > rel;
|
|
});
|
|
q.dbg.rearmBreakpoints();
|
|
returned = true;
|
|
});
|
|
CHECK(until(q, [&] { return returned.load(); }));
|
|
if (!returned.load()) { // the unfixed app: free the caller so the test can finish and report
|
|
gc.send("z0,8021c4,2");
|
|
gc.send("c");
|
|
}
|
|
caller.join();
|
|
CHECK(!ran13.load());
|
|
// gdb removes its breakpoint and continues: the frame ends, the game runs on, and the host is still there (the new
|
|
// halt cancelled the Detach's deferred drop: the app keeps seeing gdb's halts).
|
|
gc.send("z0,8021c4,2");
|
|
gc.send("c");
|
|
CHECK(until(q, [&] { return !q.dbg.halted(); }));
|
|
const uint64_t f13 = q.host.frameCount();
|
|
CHECK(until(q, [&] { return q.host.frameCount() > f13 + 5; }));
|
|
CHECK(q.dbg.core().attached());
|
|
bool ran13b = false;
|
|
CHECK(q.emu.call(
|
|
[&] {
|
|
ran13b = true;
|
|
q.host.reset();
|
|
return true;
|
|
},
|
|
[&] { return q.dbg.core().halt().halted; }));
|
|
CHECK(ran13b);
|
|
CHECK(until(q, [&] { return q.host.frameCount() > f13 + 10; }));
|
|
// #1081 M1: the app's own breakpoint (the same hot address) survives a Reset. The halt is released for the call,
|
|
// the list stays, and it is re-armed inside the call -- so the hot loop halts the fresh run at the same address and
|
|
// the mirror knows the breakpoint as ours.
|
|
CHECK(q.dbg.addBreakpoint(VJ_DBG_TARGET_M68K, 0x8021C4u));
|
|
CHECK(until(q, [&] { return q.dbg.halted() && q.dbg.haltedPc() == 0x8021C4u; }));
|
|
CHECK(!q.dbg.haltByGdb());
|
|
const uint64_t s13c = q.dbg.haltSerial();
|
|
ui("release", [&] { q.dbg.releaseHalt(); });
|
|
bool ran13c = false;
|
|
q.emu.call(
|
|
[&] {
|
|
ran13c = true;
|
|
q.host.reset();
|
|
q.dbg.rearmBreakpoints();
|
|
},
|
|
[&] {
|
|
const uint64_t rel = q.dbg.core().releaseSerial();
|
|
const HaltState h = q.dbg.core().halt();
|
|
return rel >= s13c && h.halted && h.serial > rel;
|
|
});
|
|
q.dbg.rearmBreakpoints();
|
|
CHECK(ran13c);
|
|
CHECK(until(q, [&] { return q.dbg.halted() && q.dbg.haltSerial() != s13c && q.dbg.haltedPc() == 0x8021C4u; }));
|
|
CHECK(!q.dbg.haltByGdb() && q.dbg.breakpoints().size() == 1 && q.dbg.attached());
|
|
// Detach now (no gdb breakpoint armed): the list goes, the core runs on, the host is dropped at the frame end.
|
|
ui("detach", [&] { q.dbg.detach(); });
|
|
CHECK(until(q, [&] { return !q.dbg.halted(); }));
|
|
CHECK(q.dbg.breakpoints().empty());
|
|
CHECK(until(q, [&] { return !q.dbg.core().attached() && !q.dbg.core().detachPending(); }));
|
|
const uint64_t f13c = q.host.frameCount();
|
|
CHECK(until(q, [&] { return q.host.frameCount() > f13c + 5; }));
|
|
gc.close();
|
|
q.quitting = true;
|
|
CHECK(q.emu.callFor(std::chrono::milliseconds(3000), [&] { q.host.unloadContent(); }));
|
|
CHECK(q.emu.stop());
|
|
q.unwire();
|
|
q.host.resetOptionToDefault("virtualjaguar_gdb_stub");
|
|
q.host.resetOptionToDefault("virtualjaguar_gdb_wait");
|
|
q.host.resetOptionToDefault("virtualjaguar_gdb_port");
|
|
}
|
|
|
|
// 14. #1069 inline: the same Detach with the emulation on this thread (EmuThread::Mode::Inline; App's haltedFrame is the
|
|
// pump's runUiFrame). A gdb client (its own thread) sets the breakpoint and continues; the UI frame that sees the halt
|
|
// presses Detach; the UI frame MUST run again for the re-hit (the unfixed app parks the core host-less inside retro_run
|
|
// and the window freezes until gdb continues). The client ends the run by removing its breakpoint after a bounded wait.
|
|
{
|
|
Rig q;
|
|
q.host.setDirectories(t + "system/", t + "saves/");
|
|
const std::string port = freePort();
|
|
q.host.setOptionValue("virtualjaguar_gdb_stub", "enabled");
|
|
q.host.setOptionValue("virtualjaguar_gdb_wait", "enabled");
|
|
q.host.setOptionValue("virtualjaguar_gdb_port", port);
|
|
q.wire();
|
|
q.dbg.core().waitVerdict = nullptr; // inline: the pump runs the UI frame (App::haltedFrame), not haltWait
|
|
std::atomic<int> phase{0}; // 0 waiting for gdb's halt, 1 Detach pressed, 2 the UI frame ran for the re-hit
|
|
int uiFrames = 0;
|
|
q.dbg.core().runUiFrame = [&] {
|
|
q.sync();
|
|
uiFrames++;
|
|
if (!q.dbg.halted() || q.dbg.haltedPc() != 0x8021C4u) return; // the boot halt: gdb will continue it
|
|
if (phase.load() == 0) {
|
|
CHECK(q.dbg.haltByGdb());
|
|
phase = 1;
|
|
q.dbg.detach(); // Detach inside gdb's halt
|
|
} else if (phase.load() == 1) {
|
|
CHECK(q.dbg.haltByGdb());
|
|
phase = 2; // the re-hit reached the UI: the host was kept
|
|
}
|
|
};
|
|
CHECK(q.emu.start(EmuThread::Mode::Inline, q));
|
|
CHECK(q.loadAttached(rom, err));
|
|
GdbClient gc;
|
|
CHECK(gc.connectTo(port));
|
|
std::atomic<bool> uiStarved{false};
|
|
std::thread client([&] {
|
|
gc.send("qSupported:multiprocess+");
|
|
sleepMs(50);
|
|
gc.send("Z0,8021c4,2");
|
|
sleepMs(50);
|
|
gc.send("c"); // ends the boot halt; the hot loop then halts at the breakpoint, the UI frame presses Detach
|
|
// Bounded poll (rounds, not a stopwatch) for the UI frame that follows the Detach.
|
|
for (int i = 0; i < 2500 && phase.load() < 2; i++) sleepMs(2);
|
|
if (phase.load() < 2) uiStarved = true;
|
|
gc.send("z0,8021c4,2"); // the client lets go: the frame can end
|
|
gc.send("c");
|
|
});
|
|
// The story happens inside the frame that hits the breakpoint (the boot halt, gdb's continue, the hot loop); a frame or
|
|
// two may pass first. Bounded in frames; the client's own bound ends a frame the unfixed app would never leave.
|
|
for (int i = 0; i < 300 && phase.load() < 2 && !uiStarved.load(); i++) q.emu.inlineStep();
|
|
client.join();
|
|
CHECK(!uiStarved.load());
|
|
CHECK(phase.load() == 2);
|
|
CHECK(uiFrames >= 2);
|
|
q.sync();
|
|
CHECK(!q.dbg.halted() && q.dbg.breakpoints().empty());
|
|
gc.close();
|
|
q.host.unloadContent();
|
|
q.unwire();
|
|
q.host.resetOptionToDefault("virtualjaguar_gdb_stub");
|
|
q.host.resetOptionToDefault("virtualjaguar_gdb_wait");
|
|
q.host.resetOptionToDefault("virtualjaguar_gdb_port");
|
|
}
|
|
|
|
fs::remove_all(tmp, ec);
|
|
std::printf("debug_e2e_threaded: %d checks, %d failed\n", g_checks, g_fail);
|
|
return g_fail ? 1 : 0;
|
|
}
|