Crafter.Debug/tests/ShouldPauseAndStep/main.cpp
Jorijn van der Graaf 2c77cdcfab
fix(session): report the frame a pause began at
Frames go on counting while paused, since Pump still runs, but status
gave no pause frame and `wait --paused` replied with the current one.
A wait that came minutes after `pause --on-check` caught a failure at
frame 237 said "paused at frame 5475", which an agent takes for when
the check failed.

The session records the frame in PauseNow, and status and the paused
wait report it as pausedAt. The client prints "PAUSED since frame N",
and "paused at frame N (now M)" when the two differ.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 03:21:41 +01:00

133 lines
6.2 KiB
C++

// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// Pause and step hold exactly what the app gates on them. Pausing an app
// that never calls Advance is refused rather than silently doing nothing;
// while paused Advance is false and a step lets it through that many times,
// replying once they are taken; `pause --when` stops the app the frame its
// condition holds; `pause --on-check` stops it within the frame a check
// fails, before the next Advance; TakeSteps hands a fixed-step loop every
// granted step at once; and quit calls onQuit once, on the Pump thread.
import std;
import Crafter.Debug;
using namespace Crafter::Debug;
namespace {
std::int32_t failures = 0;
void Check(bool ok, std::string_view what) {
std::println("{} {}", ok ? "PASS" : "FAIL", what);
if (!ok) ++failures;
}
class FakeExchange : public Detail::Exchange {
public:
void Reply(const Value& reply) override {
std::lock_guard lock(mutex);
if (!final) final = reply;
}
bool Stream(const Value&) override { return true; }
std::optional<Value> Final() {
std::lock_guard lock(mutex);
return final;
}
private:
std::mutex mutex;
std::optional<Value> final;
};
Session* session = nullptr;
std::shared_ptr<FakeExchange> Send(std::string_view method, std::string_view params = "{}") {
Value request = Value::MakeObject();
request.Set("token", Value(session->Token()));
request.Set("method", Value(method));
request.Set("params", Value::Parse(params));
auto exchange = std::make_shared<FakeExchange>();
session->Handle(request, exchange);
return exchange;
}
Value ResultOf(FakeExchange& exchange) {
const auto reply = exchange.Final();
return reply && reply->Find("result") ? *reply->Find("result") : Value();
}
std::string CodeOf(FakeExchange& exchange) {
const auto reply = exchange.Final();
return reply && reply->Find("error") ? reply->Find("error")->Find("code")->AsString() : std::string();
}
}
int main() {
std::int32_t counter = 0;
Watch("counter", counter);
std::int32_t quits = 0;
Session owned({ .name = "pause", .onQuit = [&] { ++quits; }, .listen = false });
session = &owned;
Check(CodeOf(*Send("debug.pause")) == "PauseUnsupported", "pausing an app that never calls Advance is refused");
Check(Advance(), "Advance is true while running");
Check(ResultOf(*Send("debug.pause")).Find("paused")->AsBool(), "pause replies at once");
Check(!Advance() && Paused(), "Advance is false while paused");
auto step = Send("debug.step", R"({"count": 2})");
Check(!step->Final(), "a step replies once its steps are taken, not before");
Check(Advance() && Advance() && !Advance(), "a step of two lets Advance through twice");
Pump();
Check(ResultOf(*step).Find("paused")->AsBool(), "the step replies on the next Pump");
Send("debug.resume");
Check(Advance() && !Paused(), "resume lets the app run");
Check(CodeOf(*Send("debug.step")) == "NotPaused", "a step while running is refused");
Check(ResultOf(*Send("debug.pause", R"({"when": "counter >= 3"})")).Find("armed")->AsString() == "when", "pause --when arms");
auto waitPaused = Send("debug.wait", R"({"paused": true})");
for (std::int32_t frame = 0; frame < 10 && Advance(); ++frame) {
++counter;
Pump();
}
Check(counter == 3 && Paused(), "pause --when stops the app the frame its condition holds");
Pump();
const Value pausedReply = ResultOf(*waitPaused);
const Value* reason = pausedReply.Find("reason");
Check(reason != nullptr && reason->AsString().find("counter >= 3") != std::string::npos, "wait --paused replies with the reason");
// Frames go on while paused: a wait that comes later must still say
// when the pause began, not the frame it looked at.
const std::uint64_t pausedAt = pausedReply.Find("pausedAt")->AsInteger();
for (std::int32_t frame = 0; frame < 5; ++frame) Pump();
const Value later = ResultOf(*Send("debug.wait", R"({"paused": true})"));
const Value statusWhilePaused = ResultOf(*Send("debug.status"));
Check(later.Find("pausedAt")->AsInteger() == static_cast<std::int64_t>(pausedAt) && later.Find("frame")->AsInteger() >= static_cast<std::int64_t>(pausedAt) + 5
&& statusWhilePaused.Find("pausedAt")->AsInteger() == static_cast<std::int64_t>(pausedAt),
std::format("a later wait and status say when the pause began (frame {}), not the frame now", pausedAt));
Send("debug.resume");
Check(ResultOf(*Send("debug.status")).Find("pausedAt") == nullptr, "status has no pausedAt once resumed");
Check(ResultOf(*Send("debug.pause", R"({"onCheck": true})")).Find("armed")->AsString() == "onCheck", "pause --on-check arms");
Check(Advance(), "running before the check fails");
Crafter::Debug::Check(false, "physics", "body {} has mass {}", 9, -1);
Check(!Advance(), "a failed check pauses before the next Advance in the same frame");
const Value status = ResultOf(*Send("debug.status"));
Check(status.Find("pauseReason")->AsString().find("check failed at main.cpp:") != std::string::npos, "the reason names the check's site");
Send("debug.resume");
Send("debug.pause");
auto steps = Send("debug.step", R"({"count": 3})");
Check(Paused() && TakeSteps() == 3 && TakeSteps() == 0, "TakeSteps hands out every granted step at once");
Pump();
Check(steps->Final().has_value() && CodeOf(*steps).empty(), "a fixed-step loop's steps reply too");
Send("debug.resume");
Check(CodeOf(*Send("debug.pause", R"({"trap": true})")) == "InvalidParams", "trap without when or onCheck is refused");
Check(CodeOf(*Send("debug.pause", R"({"when": "counter = 3"})")) == "ConditionError", "a malformed condition is refused when armed");
auto quit = Send("debug.quit");
Check(QuitRequested() && ResultOf(*quit).Find("quitting")->AsBool(), "quit replies and sets QuitRequested");
Check(quits == 0, "onQuit waits for the Pump thread");
Pump();
Pump();
Check(quits == 1, "onQuit runs once, on Pump");
return failures == 0 ? 0 : 1;
}