From bd64c44fc5da521c7a93d60038e444a6554eccfb Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Sun, 1 Nov 2026 01:53:15 +0100 Subject: [PATCH] feat(session): serve requests without blocking a thread, pause and step The app's end of the connection, independent of the transport, which reaches it through Detail::Exchange: one request's way back, safe from any thread and never blocking. - What needs only the session's own state (hello, status, list, log, pause, resume) is answered on the transport thread. What touches the app (calls, get, set, trace, screenshot) queues for the next Pump and runs on the app's thread between frames. Waits and steps reply from the Pump that sees them satisfied, events from the thread that emits them. No request ever holds a thread while it waits. - A request whose timeout passes before Pump is answered Timeout and is guaranteed not to have run, so an agent never wonders whether a command went through; an Async result arrives on the Pump it is ready; requests left when the Session ends are answered ShuttingDown. - Advance() for frame loops and Paused()/TakeSteps() for fixed-step loops hold what the app gates on them; pausing an app that gates nothing is refused instead of silently doing nothing. pause --when stops the frame its condition holds; pause --on-check stops before the next Advance in the frame a check fails, optionally raising SIGTRAP for an attached debugger. - A wait that times out says what its condition's paths were; a log follower gets matching entries in order, from the backlog on, and is dropped when it cannot keep up. - Request parameters go through Codec, so a mistyped request gets the same did-you-mean replies a mistyped command does. The session sets PR_SET_PTRACER_ANY so a debugger the agent starts can attach. Co-Authored-By: Claude Opus 5.5 --- implementations/Crafter.Debug-Registry.cpp | 27 +- implementations/Crafter.Debug-Session.cpp | 1081 +++++++++++++++++ implementations/Crafter.Debug-Timeline.cpp | 18 +- .../Crafter.Debug.Core-Protocol.cpp | 85 ++ interfaces/Crafter.Debug-Registry.cppm | 5 +- interfaces/Crafter.Debug-Session.cppm | 108 ++ interfaces/Crafter.Debug-Timeline.cppm | 4 + interfaces/Crafter.Debug.Core-Protocol.cppm | 55 + interfaces/Crafter.Debug.Core.cppm | 1 + interfaces/Crafter.Debug.cppm | 10 +- project.cpp | 17 +- tests/ShouldAnswerRequests/main.cpp | 183 +++ tests/ShouldPauseAndStep/main.cpp | 123 ++ tests/ShouldResolveWatchPaths/main.cpp | 10 +- tests/ShouldWaitTraceAndFollow/main.cpp | 157 +++ 15 files changed, 1851 insertions(+), 33 deletions(-) create mode 100644 implementations/Crafter.Debug-Session.cpp create mode 100644 implementations/Crafter.Debug.Core-Protocol.cpp create mode 100644 interfaces/Crafter.Debug-Session.cppm create mode 100644 interfaces/Crafter.Debug.Core-Protocol.cppm create mode 100644 tests/ShouldAnswerRequests/main.cpp create mode 100644 tests/ShouldPauseAndStep/main.cpp create mode 100644 tests/ShouldWaitTraceAndFollow/main.cpp diff --git a/implementations/Crafter.Debug-Registry.cpp b/implementations/Crafter.Debug-Registry.cpp index 9b09eb8..d6d38e8 100644 --- a/implementations/Crafter.Debug-Registry.cpp +++ b/implementations/Crafter.Debug-Registry.cpp @@ -156,6 +156,7 @@ std::uint64_t Registry::NewOwner() { void Registry::AddCommand(CommandEntry entry) { if (!ValidName(entry.name)) throw std::invalid_argument(std::format("Crafter.Debug: '{}' is not a command name: it must be non-empty, without whitespace", entry.name)); + if (entry.name.starts_with("debug.")) throw std::invalid_argument(std::format("Crafter.Debug: '{}' is not a command name: debug.* belongs to the session", entry.name)); std::string name = entry.name; std::lock_guard lock(mutex); commands.insert_or_assign(std::move(name), std::make_shared(std::move(entry))); @@ -255,7 +256,7 @@ Resolved Registry::Resolve(std::string_view path) const { return { *reached, {} }; } -std::optional Registry::Set(std::string_view path, const Value& value) const { +std::optional Registry::Set(std::string_view path, const Value& value) const { std::shared_ptr watch; { std::lock_guard lock(mutex); @@ -264,17 +265,23 @@ std::optional Registry::Set(std::string_view path, const Value& val } if (!watch) { const auto suggestion = SuggestPath(path); - return suggestion ? std::format("no watch '{}'; did you mean '{}'?", path, *suggestion) : std::format("no watch '{}'", path); + return Error{ "UnknownPath", suggestion ? std::format("no watch '{}'; did you mean '{}'?", path, *suggestion) : std::format("no watch '{}'", path) }; } - if (!watch->set) return std::format("'{}' is read-only", watch->path); + if (!watch->set) return Error{ "ReadOnly", std::format("'{}' is read-only", watch->path) }; const std::string_view rest = path.substr(watch->path.size()); - if (rest.empty()) return watch->set(value); - Value whole = watch->get(); - std::string error; - Value* part = Walk(whole, rest, watch->path, error); - if (part == nullptr) return error; - *part = value; - return watch->set(whole); + std::optional refused; + if (rest.empty()) { + refused = watch->set(value); + } else { + Value whole = watch->get(); + std::string error; + Value* part = Walk(whole, rest, watch->path, error); + if (part == nullptr) return Error{ "UnknownPath", error }; + *part = value; + refused = watch->set(whole); + } + if (refused) return Error{ "InvalidParams", *refused }; + return std::nullopt; } std::optional Registry::SuggestCommand(std::string_view name) const { diff --git a/implementations/Crafter.Debug-Session.cpp b/implementations/Crafter.Debug-Session.cpp new file mode 100644 index 0000000..28d56e4 --- /dev/null +++ b/implementations/Crafter.Debug-Session.cpp @@ -0,0 +1,1081 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +module; +#include +#include +#include +module Crafter.Debug:Session_impl; +import std; +import Crafter.Debug.Core; +import :Registry; +import :Timeline; +import :Condition; +import :Session; + +using namespace Crafter::Debug; +using Crafter::Debug::Detail::Exchange; + +namespace { + using Clock = std::chrono::steady_clock; + + // Request parameters, decoded through Codec so a mistyped request is + // answered with every mistake in it, as an app command would be. + struct ListParams { + std::string glob = "*"; + }; + + struct LogParams { + std::uint32_t last = 50; + std::string level = "info"; + std::string category; + std::string grep; + std::optional sinceFrame; + std::uint64_t afterSequence = 0; + std::vector kinds; + bool follow = false; + std::uint32_t forMs = 30 * 60 * 1000; + }; + + struct PauseParams { + std::optional when; + bool onCheck = false; + bool trap = false; + bool clear = false; + }; + + struct StepParams { + std::uint32_t count = 1; + }; + + struct WaitParams { + std::optional condition; + std::optional frames; + std::optional event; + bool paused = false; + }; + + struct QuitParams { + bool force = false; + }; + + struct GetParams { + std::vector paths; + }; + + struct SetParams { + Required path; + Required value; + }; + + struct TraceParams { + std::vector paths; + std::uint32_t frames = 60; + bool history = false; + }; + + struct Job { + std::string method; + Value params; + std::shared_ptr exchange; + Clock::time_point deadline; + }; + + struct Pending { + std::string method; + std::function>()> poll; + std::shared_ptr exchange; + Clock::time_point deadline; + }; + + enum class WaitKind : std::uint8_t { + Frames, + Condition, + Event, + Paused, + Steps, + }; + + struct Waiter { + WaitKind kind = WaitKind::Frames; + std::shared_ptr exchange; + Clock::time_point deadline; + Clock::time_point since; + std::uint64_t targetFrame = 0; + std::optional condition; + std::string event; + }; + + struct TraceRun { + std::vector paths; + std::uint32_t remaining = 0; + std::vector frames; + std::vector> series; + std::shared_ptr exchange; + Clock::time_point deadline; + }; + + struct Follower { + Detail::TimelineQuery query; + std::shared_ptr exchange; + Clock::time_point until; + }; + + // The screenshot source, which an app may set before its Session exists. + std::mutex captureMutex; + std::function>()> captureSource; + + // The one Session's state, for the free functions. + std::atomic current{ nullptr }; + + template + std::optional

DecodeParams(const Value& params, Exchange& exchange) { + P decoded{}; + DecodeContext context; + Crafter::Debug::Decode(params.IsNull() ? Value::MakeObject() : params, decoded, context); + if (!context.Ok()) { + exchange.Reply(MakeErrorReply(Codes::invalidParams, context.Report())); + return std::nullopt; + } + return decoded; + } + + void ReplyWith(Exchange& exchange, Result result) { + exchange.Reply(result.Ok() ? MakeReply(std::move(result.Get())) : MakeErrorReply(result.GetError().code, result.GetError().message)); + } + + Value Object(std::initializer_list> members) { + Value object = Value::MakeObject(); + for (const auto& [key, value] : members) object.Set(key, value); + return object; + } + + std::string ProgramName() { + std::error_code ec; + const std::filesystem::path exe = std::filesystem::read_symlink("/proc/self/exe", ec); + return ec ? std::string("app") : exe.filename().string(); + } + + std::string NewToken() { + std::random_device random; + return std::format("{:08x}{:08x}{:08x}{:08x}", random(), random(), random(), random()); + } + + // A tracer (a debugger) is attached to this process: TracerPid in + // /proc/self/status is nonzero. + bool TracerAttached() { + std::ifstream status("/proc/self/status"); + std::string line; + while (std::getline(status, line)) { + if (line.starts_with("TracerPid:")) return line.find_first_not_of("0\t ", 10) != std::string::npos; + } + return false; + } + + std::string FileName(std::string_view path) { + const std::size_t slash = path.rfind('/'); + return std::string(slash == std::string_view::npos ? path : path.substr(slash + 1)); + } + + std::string Abbreviated(std::string text, std::size_t limit = 200) { + if (text.size() <= limit) return text; + text.resize(limit); + return text + "..."; + } + + Value EntryValue(const TimelineEntry& entry) { + Value value = Object({ + { "sequence", Value(entry.sequence) }, + { "frame", Value(entry.frame) }, + { "seconds", Value(entry.seconds) }, + { "kind", Value(EntryKindName(entry.kind)) }, + { "category", Value(entry.category) }, + { "message", Value(entry.message) }, + { "file", Value(entry.file) }, + { "line", Value(entry.line) }, + }); + if (!entry.payload.IsNull()) value.Set("payload", entry.payload); + if (entry.kind == EntryKind::Check) value.Set("count", Value(entry.count)); + return value; + } + + std::expected ResolvePath(std::string_view path) { + Detail::Resolved resolved = Detail::Registry::Instance().Resolve(path); + if (!resolved.Ok()) return std::unexpected(std::move(resolved.error)); + return std::move(resolved.value); + } + + // min, max, mean, first, last and how often the value changed, for a + // traced series; numbers only for the arithmetic ones. + Value Stats(std::span frames, std::span values) { + Value stats = Value::MakeObject(); + if (values.empty()) return stats; + stats.Set("first", values.front()); + stats.Set("last", values.back()); + std::uint64_t changes = 0; + std::optional firstChange; + for (std::size_t i = 1; i < values.size(); ++i) { + if (!(values[i] == values[i - 1])) { + ++changes; + if (!firstChange) firstChange = frames[i]; + } + } + stats.Set("changes", Value(changes)); + if (firstChange) stats.Set("firstChangeFrame", Value(*firstChange)); + if (std::ranges::all_of(values, [](const Value& value) { return value.IsNumber(); })) { + double sum = 0; + double low = values.front().AsNumber(); + double high = low; + for (const Value& value : values) { + const double number = value.AsNumber(); + sum += number; + low = std::min(low, number); + high = std::max(high, number); + } + stats.Set("min", Value(low)); + stats.Set("max", Value(high)); + stats.Set("mean", Value(sum / static_cast(values.size()))); + } + return stats; + } + + constexpr std::array builtinMethods = { + "debug.hello", "debug.status", "debug.list", "debug.log", "debug.pause", "debug.resume", "debug.step", + "debug.wait", "debug.quit", "debug.get", "debug.set", "debug.trace", "debug.screenshot", + }; +} + +namespace Crafter::Debug { + struct Session::State { + SessionOptions options; + std::string token = NewToken(); + std::string name; + Clock::time_point started = Clock::now(); + std::int64_t pid = ::getpid(); + + // Guards what transport threads, the Pump thread and logging threads share. + std::mutex mutex; + std::deque jobs; + std::vector waiters; + std::vector followers; + std::optional armedWhen; + bool armedOnCheck = false; + bool armedTrap = false; + std::string pauseReason; + bool shutDown = false; + + // The Pump thread's own. + std::vector pendings; + std::vector traces; + bool quitHandled = false; + std::optional lastPump; + + std::atomic paused{ false }; + std::atomic steps{ 0 }; + std::atomic pauseSupported{ false }; + std::atomic quitRequested{ false }; + std::atomic frame{ 0 }; + std::atomic lastPumpNanos{ -1 }; + std::atomic fps{ 0 }; + + std::optional builtins; + + explicit State(SessionOptions given) : options(std::move(given)) { + name = options.name.empty() ? ProgramName() : options.name; + } + + void Start() { + builtins.emplace("debug"); + builtins->Watch("frame", [this] { return frame.load(); }, { .help = "frames pumped so far" }); + builtins->Watch("paused", [this] { return paused.load(); }, { .help = "whether Advance() holds the app" }); + builtins->Watch("fps", [this] { return fps.load(); }, { .help = "Pumps per second, smoothed" }); + } + + void Agent(std::string_view method, const Value& params) { + const std::string text = params.IsNull() ? std::string(method) : std::format("{} {}", method, Abbreviated(params.Dump())); + Detail::Timeline::Instance().Append(Detail::MakeEntry(EntryKind::Agent, "agent", text, std::source_location::current())); + } + + void PauseNow(std::string reason, bool trap) { + paused.store(true); + { + std::lock_guard lock(mutex); + pauseReason = reason; + } + Detail::Timeline::Instance().Append(Detail::MakeEntry(EntryKind::Pause, "debug", std::format("paused: {}", reason), std::source_location::current())); + if (trap && TracerAttached()) ::raise(SIGTRAP); + } + + // ---- requests, on transport threads ---- + + void Handle(const Value& request, const std::shared_ptr& exchange) { + try { + Dispatch(request, exchange); + } catch (const std::exception& error) { + exchange->Reply(MakeErrorReply(Codes::exception, error.what())); + } catch (...) { + exchange->Reply(MakeErrorReply(Codes::exception, "the session threw something that is not a std::exception")); + } + } + + void Dispatch(const Value& request, const std::shared_ptr& exchange) { + if (!request.IsObject()) { + return exchange->Reply(MakeErrorReply(Codes::invalidRequest, R"(a request is a JSON object: {"token", "method", "params", "timeoutMs"})")); + } + const Value* tokenValue = request.Find("token"); + if (tokenValue == nullptr || !tokenValue->IsString() || tokenValue->AsString() != token) { + return exchange->Reply(MakeErrorReply(Codes::unauthorized, "wrong or missing token: crafter-debug reads it from the instance's discovery file")); + } + const Value* methodValue = request.Find("method"); + if (methodValue == nullptr || !methodValue->IsString()) { + return exchange->Reply(MakeErrorReply(Codes::invalidRequest, "a request needs a method")); + } + const std::string method = methodValue->AsString(); + const Value* paramsValue = request.Find("params"); + const Value params = paramsValue != nullptr ? *paramsValue : Value(); + std::int64_t timeoutMs = 30'000; + if (const Value* timeout = request.Find("timeoutMs"); timeout != nullptr && timeout->IsInteger() && timeout->AsInteger() > 0) { + timeoutMs = timeout->AsInteger(); + } + const Clock::time_point deadline = Clock::now() + std::chrono::milliseconds(timeoutMs); + { + std::lock_guard lock(mutex); + if (shutDown) return exchange->Reply(MakeErrorReply(Codes::shuttingDown, "the app is shutting down")); + } + + if (method == "debug.hello") return exchange->Reply(MakeReply(Hello())); + if (method == "debug.status") return exchange->Reply(MakeReply(Status())); + if (method == "debug.list") return List(params, *exchange); + if (method == "debug.log") return Logs(params, exchange, deadline); + if (method == "debug.pause") return Pause(params, *exchange); + if (method == "debug.resume") return Resume(*exchange); + if (method == "debug.step") return Step(params, exchange, deadline); + if (method == "debug.wait") return Wait(params, exchange, deadline); + if (method == "debug.quit") return Quit(params, exchange); + + const bool builtin = std::ranges::find(builtinMethods, method) != builtinMethods.end(); + if (method.starts_with("debug.") && !builtin) { + const auto suggestion = Suggest(method, builtinMethods); + return exchange->Reply(MakeErrorReply(Codes::unknownMethod, suggestion ? std::format("no method '{}'; did you mean '{}'?", method, *suggestion) + : std::format("no method '{}'", method))); + } + if (!builtin && Detail::Registry::Instance().FindCommand(method) == nullptr) { + const auto suggestion = Detail::Registry::Instance().SuggestCommand(method); + return exchange->Reply(MakeErrorReply(Codes::unknownMethod, suggestion ? std::format("no command '{}'; did you mean '{}'?", method, *suggestion) + : std::format("no command '{}'; crafter-debug cmds lists them", method))); + } + std::lock_guard lock(mutex); + jobs.push_back({ method, params, exchange, deadline }); + } + + Value Hello() const { + return Object({ + { "protocol", Value(protocolVersion) }, + { "library", Value(libraryVersion) }, + { "name", Value(name) }, + { "pid", Value(pid) }, + }); + } + + Value Status() { + Value status = Object({ + { "name", Value(name) }, + { "pid", Value(pid) }, + { "frame", Value(frame.load()) }, + { "fps", Value(fps.load()) }, + { "paused", Value(paused.load()) }, + { "pauseSupported", Value(pauseSupported.load()) }, + { "quitRequested", Value(quitRequested.load()) }, + }); + const std::int64_t last = lastPumpNanos.load(); + const std::int64_t sinceStart = std::chrono::duration_cast(Clock::now() - started).count(); + status.Set("lastPumpAgoMs", last < 0 ? Value() : Value((sinceStart - last) / 1'000'000)); + { + std::lock_guard lock(mutex); + status.Set("pauseReason", Value(pauseReason)); + status.Set("queued", Value(jobs.size())); + status.Set("waiting", Value(waiters.size())); + status.Set("following", Value(followers.size())); + Value armed = Value::MakeObject(); + if (armedWhen) armed.Set("when", Value(armedWhen->Text())); + if (armedOnCheck) armed.Set("onCheck", Value(true)); + status.Set("armed", std::move(armed)); + } + std::uint64_t failures = 0; + Value sites = Value::MakeArray(); + for (const Detail::CheckSite& site : Detail::Timeline::Instance().CheckSites()) { + failures += site.failures; + sites.Push(Object({ { "file", Value(FileName(site.file)) }, { "line", Value(site.line) }, { "category", Value(site.category) }, { "failures", Value(site.failures) } })); + } + status.Set("checkFailures", Value(failures)); + status.Set("checkSites", std::move(sites)); + status.Set("commands", Value(Detail::Registry::Instance().Commands().size())); + status.Set("watches", Value(Detail::Registry::Instance().Watches().size())); + return status; + } + + void List(const Value& params, Exchange& exchange) { + const auto decoded = DecodeParams(params, exchange); + if (!decoded) return; + Value commands = Value::MakeArray(); + for (const auto& command : Detail::Registry::Instance().Commands(decoded->glob)) { + commands.Push(Object({ { "name", Value(command->name) }, { "description", Value(command->description) }, { "args", command->args }, { "returns", command->returns } })); + } + Value watches = Value::MakeArray(); + for (const auto& watch : Detail::Registry::Instance().Watches(decoded->glob)) { + watches.Push(Object({ { "path", Value(watch->path) }, { "type", watch->type }, { "help", Value(watch->help) }, { "readOnly", Value(!watch->set) } })); + } + Value samples = Value::MakeArray(); + for (const std::string& path : Detail::Registry::Instance().SamplePaths(decoded->glob)) samples.Push(Value(path)); + exchange.Reply(MakeReply(Object({ { "commands", std::move(commands) }, { "watches", std::move(watches) }, { "samples", std::move(samples) } }))); + } + + void Logs(const Value& params, const std::shared_ptr& exchange, Clock::time_point) { + const auto decoded = DecodeParams(params, *exchange); + if (!decoded) return; + Detail::TimelineQuery query; + query.afterSequence = decoded->afterSequence; + query.sinceFrame = decoded->sinceFrame; + query.category = decoded->category; + query.grep = decoded->grep; + if (decoded->level == "info") { + query.minLevel = 0; + } else if (decoded->level == "warn") { + query.minLevel = 1; + } else if (decoded->level == "error") { + query.minLevel = 2; + } else { + return exchange->Reply(MakeErrorReply(Codes::invalidParams, std::format("level: '{}' is not one of info, warn, error", decoded->level))); + } + for (const std::string& kind : decoded->kinds) { + bool known = false; + for (std::uint8_t k = 0; k <= static_cast(EntryKind::Stdio); ++k) { + if (EntryKindName(static_cast(k)) == kind) { + query.kinds.push_back(static_cast(k)); + known = true; + } + } + if (!known) return exchange->Reply(MakeErrorReply(Codes::invalidParams, std::format("kinds: '{}' is not one of log, warn, error, check, event, agent, pause, stdio", kind))); + } + Detail::TimelineQuery backlog = query; + backlog.last = decoded->last; + if (!decoded->follow) { + Value entries = Value::MakeArray(); + for (const TimelineEntry& entry : Detail::Timeline::Instance().Query(backlog)) entries.Push(EntryValue(entry)); + return exchange->Reply(MakeReply(Object({ { "entries", std::move(entries) }, { "lastSequence", Value(Detail::Timeline::Instance().LastSequence()) } }))); + } + // The backlog and the registration happen under one lock, which + // the listener also takes before streaming, so a follower sees + // every entry once and in order. Stream never blocks. + std::lock_guard lock(mutex); + const std::uint64_t upTo = Detail::Timeline::Instance().LastSequence(); + for (const TimelineEntry& entry : Detail::Timeline::Instance().Query(backlog)) { + if (entry.sequence > upTo) continue; + if (!exchange->Stream(Object({ { "entry", EntryValue(entry) } }))) return; + } + Follower follower{ query, exchange, Clock::now() + std::chrono::milliseconds(decoded->forMs) }; + follower.query.afterSequence = std::max(query.afterSequence, upTo); + followers.push_back(std::move(follower)); + } + + void Pause(const Value& params, Exchange& exchange) { + const auto decoded = DecodeParams(params, exchange); + if (!decoded) return; + if (decoded->clear) { + { + std::lock_guard lock(mutex); + armedWhen.reset(); + armedOnCheck = false; + armedTrap = false; + } + Agent("pause --clear", Value()); + return exchange.Reply(MakeReply(Object({ { "armed", Value(false) } }))); + } + if (!pauseSupported.load()) { + return exchange.Reply(MakeErrorReply(Codes::pauseUnsupported, "this app never calls Debug::Advance() or Debug::Paused(), so pausing would hold nothing; " + "gate the simulation on them (see the README)")); + } + if (decoded->trap && !decoded->when && !decoded->onCheck) { + return exchange.Reply(MakeErrorReply(Codes::invalidParams, "trap needs when or onCheck: it stops the app where that happens")); + } + if (decoded->when) { + std::optional condition; + try { + condition = Condition::Parse(*decoded->when); + } catch (const ConditionError& error) { + return exchange.Reply(MakeErrorReply(Codes::conditionError, error.what())); + } + { + std::lock_guard lock(mutex); + armedWhen = std::move(condition); + armedTrap = decoded->trap; + } + Agent("pause --when", Value(*decoded->when)); + return exchange.Reply(MakeReply(Object({ { "armed", Value("when") }, { "condition", Value(*decoded->when) } }))); + } + if (decoded->onCheck) { + { + std::lock_guard lock(mutex); + armedOnCheck = true; + armedTrap = decoded->trap; + } + Agent("pause --on-check", Value()); + return exchange.Reply(MakeReply(Object({ { "armed", Value("onCheck") } }))); + } + Agent("pause", Value()); + PauseNow("an agent asked", false); + exchange.Reply(MakeReply(Object({ { "paused", Value(true) }, { "frame", Value(frame.load()) } }))); + } + + void Resume(Exchange& exchange) { + Agent("resume", Value()); + steps.store(0); + paused.store(false); + { + std::lock_guard lock(mutex); + pauseReason.clear(); + } + Detail::Timeline::Instance().Append(Detail::MakeEntry(EntryKind::Pause, "debug", "resumed", std::source_location::current())); + exchange.Reply(MakeReply(Object({ { "paused", Value(false) }, { "frame", Value(frame.load()) } }))); + } + + void Step(const Value& params, const std::shared_ptr& exchange, Clock::time_point deadline) { + const auto decoded = DecodeParams(params, *exchange); + if (!decoded) return; + if (!pauseSupported.load()) { + return exchange->Reply(MakeErrorReply(Codes::pauseUnsupported, "this app never calls Debug::Advance() or Debug::Paused(), so there is nothing to step")); + } + if (!paused.load()) return exchange->Reply(MakeErrorReply(Codes::notPaused, "step works while paused: run crafter-debug pause first")); + if (decoded->count == 0) return exchange->Reply(MakeErrorReply(Codes::invalidParams, "count: step at least 1")); + Agent("step", Object({ { "count", Value(decoded->count) } })); + std::lock_guard lock(mutex); + steps.fetch_add(decoded->count); + waiters.push_back({ .kind = WaitKind::Steps, .exchange = exchange, .deadline = deadline, .since = Clock::now() }); + } + + void Wait(const Value& params, const std::shared_ptr& exchange, Clock::time_point deadline) { + const auto decoded = DecodeParams(params, *exchange); + if (!decoded) return; + const std::int32_t asked = (decoded->condition ? 1 : 0) + (decoded->frames ? 1 : 0) + (decoded->event ? 1 : 0) + (decoded->paused ? 1 : 0); + if (asked != 1) return exchange->Reply(MakeErrorReply(Codes::invalidParams, "wait for one thing: condition, frames, event or paused")); + Waiter waiter{ .exchange = exchange, .deadline = deadline, .since = Clock::now() }; + if (decoded->condition) { + try { + waiter.condition = Condition::Parse(*decoded->condition); + } catch (const ConditionError& error) { + return exchange->Reply(MakeErrorReply(Codes::conditionError, error.what())); + } + waiter.kind = WaitKind::Condition; + } else if (decoded->frames) { + waiter.kind = WaitKind::Frames; + waiter.targetFrame = frame.load() + *decoded->frames; + } else if (decoded->event) { + waiter.kind = WaitKind::Event; + waiter.event = *decoded->event; + } else { + waiter.kind = WaitKind::Paused; + if (paused.load()) { + std::lock_guard lock(mutex); + return exchange->Reply(MakeReply(Object({ { "frame", Value(frame.load()) }, { "reason", Value(pauseReason) } }))); + } + } + std::lock_guard lock(mutex); + waiters.push_back(std::move(waiter)); + } + + void Quit(const Value& params, const std::shared_ptr& exchange) { + const auto decoded = DecodeParams(params, *exchange); + if (!decoded) return; + Agent(decoded->force ? "quit --force" : "quit", Value()); + quitRequested.store(true); + if (decoded->force) { + // From the thread the reply leaves on, so the main loop + // need not run: --force is for an app whose loop is stuck. + return exchange->ReplyThen(MakeReply(Object({ { "quitting", Value(true) }, { "force", Value(true) } })), [] { std::_Exit(0); }); + } + exchange->Reply(MakeReply(Object({ { "quitting", Value(true) } }))); + } + + // ---- the Pump thread ---- + + void Pump() { + const Clock::time_point now = Clock::now(); + const std::uint64_t current = frame.fetch_add(1) + 1; + Detail::SetFrame(current); + if (lastPump) { + const double seconds = std::chrono::duration(now - *lastPump).count(); + if (seconds > 0) { + const double previous = fps.load(); + fps.store(previous == 0 ? 1 / seconds : previous * 0.9 + 0.1 / seconds); + } + } + lastPump = now; + lastPumpNanos.store(std::chrono::duration_cast(now - started).count()); + + RunJobs(now); + PollPendings(now); + CheckArmed(); + ServeWaiters(now, current); + RecordTraces(now, current); + ExpireFollowers(now); + if (quitRequested.load() && !quitHandled) { + quitHandled = true; + if (options.onQuit) options.onQuit(); + } + } + + void RunJobs(Clock::time_point now) { + std::deque taken; + { + std::lock_guard lock(mutex); + taken.swap(jobs); + } + for (Job& job : taken) { + if (now > job.deadline) { + job.exchange->Reply(MakeErrorReply(Codes::timeout, "not run: the app did not Pump before the request's timeout")); + continue; + } + try { + Execute(job); + } catch (const std::exception& error) { + job.exchange->Reply(MakeErrorReply(Codes::exception, error.what())); + } catch (...) { + job.exchange->Reply(MakeErrorReply(Codes::exception, "something that is not a std::exception was thrown")); + } + } + } + + void Execute(Job& job) { + Agent(job.method, job.params); + if (job.method == "debug.get") return Get(job); + if (job.method == "debug.set") return Set(job); + if (job.method == "debug.trace") return StartTrace(job); + if (job.method == "debug.screenshot") return Screenshot(job); + const auto command = Detail::Registry::Instance().FindCommand(job.method); + if (!command) return job.exchange->Reply(MakeErrorReply(Codes::unknownMethod, std::format("'{}' was unregistered before it ran", job.method))); + Detail::CommandOutcome outcome = command->invoke(job.params); + if (outcome.done) return ReplyWith(*job.exchange, std::move(*outcome.done)); + pendings.push_back({ job.method, std::move(outcome.pending), job.exchange, job.deadline }); + } + + void Get(Job& job) { + const auto decoded = DecodeParams(job.params, *job.exchange); + if (!decoded) return; + if (decoded->paths.empty()) return job.exchange->Reply(MakeErrorReply(Codes::invalidParams, "paths: name at least one path, or a glob such as 'fort1.*'")); + Detail::Registry& registry = Detail::Registry::Instance(); + Value values = Value::MakeObject(); + Value errors = Value::MakeObject(); + const auto read = [&](std::string_view path) { + Detail::Resolved resolved = registry.Resolve(path); + if (resolved.Ok()) { + values.Set(path, std::move(resolved.value)); + } else { + errors.Set(path, Value(resolved.error)); + } + }; + for (const std::string& path : decoded->paths) { + if (path.find_first_of("*?") == std::string::npos) { + read(path); + continue; + } + const auto watches = registry.Watches(path); + const auto samples = registry.SamplePaths(path); + if (watches.empty() && samples.empty()) errors.Set(path, Value("no watch or sample matches")); + for (const auto& watch : watches) read(watch->path); + for (const std::string& sample : samples) { + if (values.Find(sample) == nullptr) read(sample); + } + } + if (values.Size() == 0 && errors.Size() != 0) { + std::string message; + for (const auto& [path, error] : errors.Members()) message += message.empty() ? error.AsString() : "; " + error.AsString(); + return job.exchange->Reply(MakeErrorReply(Codes::unknownPath, message)); + } + Value result = Object({ { "values", std::move(values) } }); + if (errors.Size() != 0) result.Set("errors", std::move(errors)); + job.exchange->Reply(MakeReply(std::move(result))); + } + + void Set(Job& job) { + const auto decoded = DecodeParams(job.params, *job.exchange); + if (!decoded) return; + if (const auto error = Detail::Registry::Instance().Set(*decoded->path, *decoded->value)) { + return job.exchange->Reply(MakeErrorReply(error->code, error->message)); + } + Detail::Resolved now = Detail::Registry::Instance().Resolve(*decoded->path); + job.exchange->Reply(MakeReply(Object({ { "path", Value(*decoded->path) }, { "value", now.Ok() ? now.value : *decoded->value } }))); + } + + void StartTrace(Job& job) { + const auto decoded = DecodeParams(job.params, *job.exchange); + if (!decoded) return; + if (decoded->paths.empty()) return job.exchange->Reply(MakeErrorReply(Codes::invalidParams, "paths: name at least one path")); + Detail::Registry& registry = Detail::Registry::Instance(); + if (decoded->history) { + Value series = Value::MakeObject(); + for (const std::string& path : decoded->paths) { + const std::vector points = registry.SampleHistory(path); + if (points.empty()) return job.exchange->Reply(MakeErrorReply(Codes::unknownPath, std::format("no sample history for '{}'; history is kept for Sample() paths", path))); + std::vector frames; + std::vector values; + for (const Detail::SamplePoint& point : points) { + frames.push_back(point.frame); + values.push_back(point.value); + } + Value framesValue = Value::MakeArray(); + for (const std::uint64_t f : frames) framesValue.Push(Value(f)); + series.Set(path, Object({ { "frames", std::move(framesValue) }, { "values", Value::MakeArray(values) }, { "stats", Stats(frames, values) } })); + } + return job.exchange->Reply(MakeReply(Object({ { "series", std::move(series) } }))); + } + if (decoded->frames == 0) return job.exchange->Reply(MakeErrorReply(Codes::invalidParams, "frames: trace at least 1 frame")); + for (const std::string& path : decoded->paths) { + const Detail::Resolved resolved = registry.Resolve(path); + if (!resolved.Ok()) return job.exchange->Reply(MakeErrorReply(Codes::unknownPath, resolved.error)); + } + traces.push_back({ decoded->paths, decoded->frames, {}, std::vector>(decoded->paths.size()), job.exchange, job.deadline }); + } + + void Screenshot(Job& job) { + std::function>()> source; + { + std::lock_guard lock(captureMutex); + source = captureSource; + } + if (!source) { + return job.exchange->Reply(MakeErrorReply(Codes::noCapture, "this app has no screenshot source: call Debug::SetCapture (the README has the Crafter.Graphics version)")); + } + pendings.push_back({ + "debug.screenshot", + [async = source()]() mutable -> std::optional> { + std::optional>> polled = async.Poll(); + if (!polled) return std::nullopt; + if (!polled->Ok()) return Result(polled->GetError()); + const std::vector& png = polled->Get(); + return Result(Object({ { "png", Value(Base64Encode(png)) }, { "bytes", Value(png.size()) } })); + }, + job.exchange, + job.deadline, + }); + } + + void PollPendings(Clock::time_point now) { + for (auto it = pendings.begin(); it != pendings.end();) { + try { + std::optional> polled = it->poll(); + if (polled) { + ReplyWith(*it->exchange, std::move(*polled)); + it = pendings.erase(it); + continue; + } + } catch (const std::exception& error) { + it->exchange->Reply(MakeErrorReply(Codes::exception, std::format("the result threw: {}", error.what()))); + it = pendings.erase(it); + continue; + } catch (...) { + it->exchange->Reply(MakeErrorReply(Codes::exception, "the result threw something that is not a std::exception")); + it = pendings.erase(it); + continue; + } + if (now > it->deadline) { + it->exchange->Reply(MakeErrorReply(Codes::timeout, std::format("{} ran, but its result was not ready before the request's timeout", it->method))); + it = pendings.erase(it); + continue; + } + ++it; + } + } + + void CheckArmed() { + std::optional when; + bool trap = false; + { + std::lock_guard lock(mutex); + if (!armedWhen) return; + when = armedWhen; + trap = armedTrap; + } + const auto result = when->Evaluate(ResolvePath); + if (result && !*result) return; + { + std::lock_guard lock(mutex); + armedWhen.reset(); + } + if (!result) { + Detail::Timeline::Instance().Append(Detail::MakeEntry(EntryKind::Warn, "debug", std::format("pause --when '{}' disarmed: {}", when->Text(), result.error()), std::source_location::current())); + return; + } + PauseNow(std::format("'{}' became true", when->Text()), trap); + } + + std::string TimeoutMessage(const Waiter& waiter) const { + const auto waited = std::chrono::duration_cast(Clock::now() - waiter.since).count(); + switch (waiter.kind) { + case WaitKind::Frames: return std::format("frame {} was not reached within {} ms; the app is at frame {}", waiter.targetFrame, waited, frame.load()); + case WaitKind::Paused: return std::format("the app did not pause within {} ms", waited); + case WaitKind::Steps: return std::format("the steps were not taken within {} ms: does the app call Debug::Advance() or TakeSteps() every frame?", waited); + case WaitKind::Event: return std::format("no '{}' event within {} ms", waiter.event, waited); + case WaitKind::Condition: { + std::string values; + for (const std::string& path : waiter.condition->Paths()) { + const auto value = ResolvePath(path); + if (!values.empty()) values += ", "; + values += value ? std::format("{} is {}", path, Abbreviated(value->Dump(), 80)) : std::format("{}: {}", path, value.error()); + } + return std::format("'{}' was not true within {} ms{}", waiter.condition->Text(), waited, values.empty() ? std::string() : std::format(" ({})", values)); + } + } + return "timed out"; + } + + void ServeWaiters(Clock::time_point now, std::uint64_t current) { + // Event waiters stay in the list, where the listener finds them. + std::vector taken; + { + std::lock_guard lock(mutex); + for (auto it = waiters.begin(); it != waiters.end();) { + if (it->kind == WaitKind::Event) { + if (now > it->deadline) { + it->exchange->Reply(MakeErrorReply(Codes::timeout, TimeoutMessage(*it))); + it = waiters.erase(it); + } else { + ++it; + } + continue; + } + taken.push_back(std::move(*it)); + it = waiters.erase(it); + } + } + std::vector remaining; + for (Waiter& waiter : taken) { + bool done = false; + switch (waiter.kind) { + case WaitKind::Frames: + if (current >= waiter.targetFrame) { + waiter.exchange->Reply(MakeReply(Object({ { "frame", Value(current) } }))); + done = true; + } + break; + case WaitKind::Condition: { + const auto result = waiter.condition->Evaluate(ResolvePath); + if (!result) { + waiter.exchange->Reply(MakeErrorReply(Codes::conditionError, result.error())); + done = true; + } else if (*result) { + waiter.exchange->Reply(MakeReply(Object({ { "frame", Value(current) }, { "condition", Value(waiter.condition->Text()) } }))); + done = true; + } + break; + } + case WaitKind::Paused: + if (paused.load()) { + std::string reason; + { + std::lock_guard lock(mutex); + reason = pauseReason; + } + waiter.exchange->Reply(MakeReply(Object({ { "frame", Value(current) }, { "reason", Value(reason) } }))); + done = true; + } + break; + case WaitKind::Steps: + if (steps.load() == 0) { + waiter.exchange->Reply(MakeReply(Object({ { "frame", Value(current) }, { "paused", Value(paused.load()) } }))); + done = true; + } + break; + case WaitKind::Event: break; + } + if (!done && now > waiter.deadline) { + waiter.exchange->Reply(MakeErrorReply(Codes::timeout, TimeoutMessage(waiter))); + done = true; + } + if (!done) remaining.push_back(std::move(waiter)); + } + std::lock_guard lock(mutex); + for (Waiter& waiter : remaining) waiters.push_back(std::move(waiter)); + } + + void RecordTraces(Clock::time_point now, std::uint64_t current) { + for (auto it = traces.begin(); it != traces.end();) { + TraceRun& run = *it; + run.frames.push_back(current); + for (std::size_t i = 0; i < run.paths.size(); ++i) { + const auto value = ResolvePath(run.paths[i]); + run.series[i].push_back(value ? *value : Value()); + } + const bool truncated = now > run.deadline; + if (--run.remaining != 0 && !truncated) { + ++it; + continue; + } + Value frames = Value::MakeArray(); + for (const std::uint64_t f : run.frames) frames.Push(Value(f)); + Value series = Value::MakeObject(); + for (std::size_t i = 0; i < run.paths.size(); ++i) { + series.Set(run.paths[i], Object({ { "values", Value::MakeArray(run.series[i]) }, { "stats", Stats(run.frames, run.series[i]) } })); + } + Value result = Object({ { "frames", std::move(frames) }, { "series", std::move(series) } }); + if (truncated) result.Set("truncated", Value(true)); + run.exchange->Reply(MakeReply(std::move(result))); + it = traces.erase(it); + } + } + + void ExpireFollowers(Clock::time_point now) { + std::vector> ended; + { + std::lock_guard lock(mutex); + for (auto it = followers.begin(); it != followers.end();) { + if (now > it->until) { + ended.push_back(it->exchange); + it = followers.erase(it); + } else { + ++it; + } + } + } + for (const auto& exchange : ended) exchange->Reply(MakeReply(Object({ { "ended", Value("the follow time elapsed") } }))); + } + + // ---- every timeline entry, on the thread that recorded it ---- + + void OnEntry(const TimelineEntry& entry) { + if (entry.kind == EntryKind::Check) { + bool trigger = false; + bool trap = false; + { + std::lock_guard lock(mutex); + if (armedOnCheck) { + armedOnCheck = false; + trigger = true; + trap = armedTrap; + } + } + if (trigger) PauseNow(std::format("check failed at {}:{}: {}", FileName(entry.file), entry.line, entry.message), trap); + } + if (entry.kind == EntryKind::Event) { + std::vector> matched; + { + std::lock_guard lock(mutex); + for (auto it = waiters.begin(); it != waiters.end();) { + if (it->kind == WaitKind::Event && it->event == entry.category) { + matched.push_back(it->exchange); + it = waiters.erase(it); + } else { + ++it; + } + } + } + for (const auto& exchange : matched) { + exchange->Reply(MakeReply(Object({ { "frame", Value(entry.frame) }, { "event", Value(entry.category) }, { "payload", entry.payload } }))); + } + } + if (entry.sequence == 0) return; + std::lock_guard lock(mutex); + std::erase_if(followers, [&](Follower& follower) { + if (!Detail::Matches(follower.query, entry)) return false; + return !follower.exchange->Stream(Object({ { "entry", EntryValue(entry) } })); + }); + } + + // ---- the end, on the thread destroying the Session ---- + + void Shutdown() { + Detail::Timeline::Instance().SetListener(nullptr); + std::deque queued; + std::vector waiting; + std::vector following; + { + std::lock_guard lock(mutex); + shutDown = true; + queued.swap(jobs); + waiting.swap(waiters); + following.swap(followers); + } + const Value goodbye = MakeErrorReply(Codes::shuttingDown, "the app is shutting down"); + for (const Job& job : queued) job.exchange->Reply(goodbye); + for (const Waiter& waiter : waiting) waiter.exchange->Reply(goodbye); + for (const Follower& follower : following) follower.exchange->Reply(goodbye); + for (const Pending& pending : pendings) pending.exchange->Reply(goodbye); + for (const TraceRun& run : traces) run.exchange->Reply(goodbye); + pendings.clear(); + traces.clear(); + builtins.reset(); + } + }; +} + +Session::Session(SessionOptions options) : state(std::make_shared(std::move(options))) { + State* expected = nullptr; + if (!current.compare_exchange_strong(expected, state.get())) { + throw std::logic_error("Crafter.Debug: a Session already exists; there is one per process"); + } + try { + state->Start(); + std::weak_ptr weak = state; + Detail::Timeline::Instance().SetListener([weak](const TimelineEntry& entry) { + if (const std::shared_ptr alive = weak.lock()) alive->OnEntry(entry); + }); + // A debugger the agent starts is never this process's ancestor, + // which Yama's ptrace_scope 1 would otherwise require. + ::prctl(PR_SET_PTRACER, PR_SET_PTRACER_ANY, 0, 0, 0); + } catch (...) { + current.store(nullptr); + throw; + } +} + +Session::~Session() { + state->Shutdown(); + current.store(nullptr); +} + +void Session::Handle(const Value& request, std::shared_ptr exchange) { + state->Handle(request, exchange); +} + +const std::string& Session::Token() const { + return state->token; +} + +void Crafter::Debug::Pump() { + if (Session::State* session = current.load()) session->Pump(); +} + +bool Crafter::Debug::Advance() { + Session::State* session = current.load(); + if (session == nullptr) return true; + session->pauseSupported.store(true, std::memory_order_relaxed); + if (!session->paused.load()) return true; + std::uint32_t available = session->steps.load(); + while (available > 0) { + if (session->steps.compare_exchange_weak(available, available - 1)) return true; + } + return false; +} + +bool Crafter::Debug::Paused() { + Session::State* session = current.load(); + if (session == nullptr) return false; + session->pauseSupported.store(true, std::memory_order_relaxed); + return session->paused.load(); +} + +std::uint32_t Crafter::Debug::TakeSteps() { + Session::State* session = current.load(); + if (session == nullptr) return 0; + session->pauseSupported.store(true, std::memory_order_relaxed); + return session->steps.exchange(0); +} + +bool Crafter::Debug::QuitRequested() { + Session::State* session = current.load(); + return session != nullptr && session->quitRequested.load(); +} + +void Crafter::Debug::SetCapture(std::function>()> capture) { + std::lock_guard lock(captureMutex); + captureSource = std::move(capture); +} diff --git a/implementations/Crafter.Debug-Timeline.cpp b/implementations/Crafter.Debug-Timeline.cpp index c09ed61..6dee57a 100644 --- a/implementations/Crafter.Debug-Timeline.cpp +++ b/implementations/Crafter.Debug-Timeline.cpp @@ -88,17 +88,21 @@ void Timeline::RecordCheck(std::string_view category, std::string message, const Notify(entry); } +bool Crafter::Debug::Detail::Matches(const TimelineQuery& query, const TimelineEntry& entry) { + if (entry.sequence <= query.afterSequence) return false; + if (query.sinceFrame && entry.frame < *query.sinceFrame) return false; + if (Level(entry.kind) < query.minLevel) return false; + if (!query.kinds.empty() && std::ranges::find(query.kinds, entry.kind) == query.kinds.end()) return false; + if (!query.category.empty() && !GlobMatch(query.category, entry.category)) return false; + if (!query.grep.empty() && entry.message.find(query.grep) == std::string::npos && entry.category.find(query.grep) == std::string::npos) return false; + return true; +} + std::vector Timeline::Query(const TimelineQuery& query) const { std::vector matched; std::lock_guard lock(mutex); for (const TimelineEntry& entry : entries) { - if (entry.sequence <= query.afterSequence) continue; - if (query.sinceFrame && entry.frame < *query.sinceFrame) continue; - if (Level(entry.kind) < query.minLevel) continue; - if (!query.kinds.empty() && std::ranges::find(query.kinds, entry.kind) == query.kinds.end()) continue; - if (!query.category.empty() && !GlobMatch(query.category, entry.category)) continue; - if (!query.grep.empty() && entry.message.find(query.grep) == std::string::npos && entry.category.find(query.grep) == std::string::npos) continue; - matched.push_back(entry); + if (Matches(query, entry)) matched.push_back(entry); } if (query.last != 0 && matched.size() > query.last) matched.erase(matched.begin(), matched.end() - static_cast(query.last)); return matched; diff --git a/implementations/Crafter.Debug.Core-Protocol.cpp b/implementations/Crafter.Debug.Core-Protocol.cpp new file mode 100644 index 0000000..005d70a --- /dev/null +++ b/implementations/Crafter.Debug.Core-Protocol.cpp @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +module Crafter.Debug.Core:Protocol_impl; +import std; +import :Value; +import :Protocol; + +using namespace Crafter::Debug; + +namespace { + constexpr std::string_view alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + + std::int32_t Sextet(char c) { + if (c >= 'A' && c <= 'Z') return c - 'A'; + if (c >= 'a' && c <= 'z') return c - 'a' + 26; + if (c >= '0' && c <= '9') return c - '0' + 52; + if (c == '+') return 62; + if (c == '/') return 63; + return -1; + } +} + +Value Crafter::Debug::MakeReply(Value result) { + Value reply = Value::MakeObject(); + reply.Set("result", std::move(result)); + return reply; +} + +Value Crafter::Debug::MakeErrorReply(std::string_view code, std::string_view message) { + Value error = Value::MakeObject(); + error.Set("code", Value(code)); + error.Set("message", Value(message)); + Value reply = Value::MakeObject(); + reply.Set("error", std::move(error)); + return reply; +} + +std::string Crafter::Debug::Base64Encode(std::span bytes) { + std::string out; + out.reserve((bytes.size() + 2) / 3 * 4); + std::size_t i = 0; + for (; i + 3 <= bytes.size(); i += 3) { + const std::uint32_t chunk = (std::to_integer(bytes[i]) << 16) | (std::to_integer(bytes[i + 1]) << 8) | std::to_integer(bytes[i + 2]); + out += alphabet[(chunk >> 18) & 63]; + out += alphabet[(chunk >> 12) & 63]; + out += alphabet[(chunk >> 6) & 63]; + out += alphabet[chunk & 63]; + } + const std::size_t rest = bytes.size() - i; + if (rest == 1) { + const std::uint32_t chunk = std::to_integer(bytes[i]) << 16; + out += alphabet[(chunk >> 18) & 63]; + out += alphabet[(chunk >> 12) & 63]; + out += "=="; + } else if (rest == 2) { + const std::uint32_t chunk = (std::to_integer(bytes[i]) << 16) | (std::to_integer(bytes[i + 1]) << 8); + out += alphabet[(chunk >> 18) & 63]; + out += alphabet[(chunk >> 12) & 63]; + out += alphabet[(chunk >> 6) & 63]; + out += '='; + } + return out; +} + +std::optional> Crafter::Debug::Base64Decode(std::string_view text) { + if (text.size() % 4 != 0) return std::nullopt; + std::vector out; + out.reserve(text.size() / 4 * 3); + for (std::size_t i = 0; i < text.size(); i += 4) { + const bool last = i + 4 == text.size(); + const std::int32_t a = Sextet(text[i]); + const std::int32_t b = Sextet(text[i + 1]); + const bool pad2 = last && text[i + 2] == '='; + const bool pad3 = last && text[i + 3] == '='; + const std::int32_t c = pad2 ? 0 : Sextet(text[i + 2]); + const std::int32_t d = pad3 ? 0 : Sextet(text[i + 3]); + if (a < 0 || b < 0 || c < 0 || d < 0 || (pad2 && !pad3)) return std::nullopt; + const std::uint32_t chunk = (static_cast(a) << 18) | (static_cast(b) << 12) | (static_cast(c) << 6) | static_cast(d); + out.push_back(static_cast((chunk >> 16) & 0xFF)); + if (!pad2) out.push_back(static_cast((chunk >> 8) & 0xFF)); + if (!pad3) out.push_back(static_cast(chunk & 0xFF)); + } + return out; +} diff --git a/interfaces/Crafter.Debug-Registry.cppm b/interfaces/Crafter.Debug-Registry.cppm index a7a79f6..36fba79 100644 --- a/interfaces/Crafter.Debug-Registry.cppm +++ b/interfaces/Crafter.Debug-Registry.cppm @@ -103,8 +103,9 @@ export namespace Crafter::Debug::Detail { Resolved Resolve(std::string_view path) const; // Sets the watch the path names, or a part of its value: "fort1.core.pos.x" // reads the watch "fort1.core.pos", replaces x and writes it back. - // Returns an error message, or nothing. Call on the Pump thread. - std::optional Set(std::string_view path, const Value& value) const; + // Returns why not (UnknownPath, ReadOnly or InvalidParams), or + // nothing. Call on the Pump thread. + std::optional Set(std::string_view path, const Value& value) const; // The closest known command, watch or sample name to `name`, for // "did you mean" in errors. diff --git a/interfaces/Crafter.Debug-Session.cppm b/interfaces/Crafter.Debug-Session.cppm new file mode 100644 index 0000000..5af78cd --- /dev/null +++ b/interfaces/Crafter.Debug-Session.cppm @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// The session: the app's end of the debug connection. +// +// ThreadPool::Start(); +// Debug::Session debug({ .name = "MyApp" }); +// EventListener pump(&window.onBeforeUpdate, [] { Debug::Pump(); }); +// +// Requests arrive on transport threads and are never answered by blocking +// one: what needs only the session's own state (status, the command list, +// the log, pause, resume) is answered there and then; what touches the app +// (calls, watches, traces, screenshots) waits in a queue for the next Pump, +// which runs it on the app's own thread between frames; waits and steps are +// answered by the Pump that sees them satisfied. A request that cannot run +// before its timeout is answered Timeout and is guaranteed not to have run. +// +// Pause and step only stop what the app gates on them: +// +// while (acc >= dt && Debug::Advance()) { sim.Tick(); acc -= dt; } // frame loops +// if (Debug::Paused()) { for (auto n = Debug::TakeSteps(); n > 0; --n) sim.Tick(); acc = 0; } +// // fixed-step loops +// +// Rendering and Pump keep running while paused, so the agent can still read +// watches, take screenshots and step. +export module Crafter.Debug:Session; +import std; +import Crafter.Debug.Core; + +export namespace Crafter::Debug::Detail { + // One request's way back to its client. A transport implements it; the + // session calls it from transport threads, the Pump thread and whatever + // thread logs, so it must be safe from any thread and must not block. + class Exchange { + public: + virtual ~Exchange() = default; + // The final reply, ending the exchange. + virtual void Reply(const Value& reply) = 0; + // One more object on a streaming exchange (`log --follow`). False + // when the client is gone or too far behind; the session then drops it. + virtual bool Stream(const Value& line) = 0; + // Reply, then call `afterSent` once the reply has left: what + // `quit --force` exits from. + virtual void ReplyThen(const Value& reply, std::function afterSent) { + Reply(reply); + afterSent(); + } + }; +} + +export namespace Crafter::Debug { + struct SessionOptions { + // What `crafter-debug ls` calls this instance; the executable's + // file name when empty. + std::string name; + // 0 for any free port; the discovery file says which. + std::uint16_t port = 0; + // Also record what the app writes to stdout and stderr on the timeline. + bool captureStdio = false; + // Called on the Pump thread when an agent asks the app to quit, + // after QuitRequested() turned true. Don't call std::exit from it: + // close the window or end the loop, so the Session is destroyed + // before main returns. + std::function onQuit; + // False: no transport and no discovery file; requests come only + // through Handle. For tests. + bool listen = true; + }; + + // One per process; a second throws. Destroy it before + // ThreadPool::Stop() and before main returns: it closes its connections, + // and msquic waits for open ones at exit. + class Session { + public: + explicit Session(SessionOptions options = {}); + ~Session(); + Session(const Session&) = delete; + Session& operator=(const Session&) = delete; + + // One request, from a transport or a test. Never blocks; the reply + // goes through `exchange`. + void Handle(const Value& request, std::shared_ptr exchange); + // What every request must carry. + const std::string& Token() const; + + struct State; + + private: + std::shared_ptr state; + }; + + // Once per frame, on the thread that owns the app's state: runs queued + // requests, polls Async results, evaluates waits and conditions, records + // traces, and counts the frame. Does nothing without a Session. + void Pump(); + // For frame loops: false while paused, true once per step granted. + bool Advance(); + // For fixed-step loops: whether the session is paused; see TakeSteps. + bool Paused(); + // The steps granted since the last call, all at once. + std::uint32_t TakeSteps(); + // Whether an agent asked the app to quit. + bool QuitRequested(); + // Where `crafter-debug screenshot` gets a PNG: called on the Pump + // thread, returning the bytes when they are ready (a frame or two later + // for a GPU readback). See the README for the Crafter.Graphics version. + void SetCapture(std::function>()> capture); +} diff --git a/interfaces/Crafter.Debug-Timeline.cppm b/interfaces/Crafter.Debug-Timeline.cppm index c981e7d..b7296f6 100644 --- a/interfaces/Crafter.Debug-Timeline.cppm +++ b/interfaces/Crafter.Debug-Timeline.cppm @@ -87,6 +87,10 @@ export namespace Crafter::Debug::Detail { std::vector kinds; }; + // Whether one entry passes a query's filters (all but `last`), for a + // follower that sees entries one at a time. + bool Matches(const TimelineQuery& query, const TimelineEntry& entry); + struct CheckSite { std::string file; std::uint32_t line = 0; diff --git a/interfaces/Crafter.Debug.Core-Protocol.cppm b/interfaces/Crafter.Debug.Core-Protocol.cppm new file mode 100644 index 0000000..2885938 --- /dev/null +++ b/interfaces/Crafter.Debug.Core-Protocol.cppm @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// The wire contract between an app and crafter-debug. A request is one JSON +// object, +// +// {"token": "...", "method": "debug.get", "params": {...}, "timeoutMs": 30000} +// +// and its reply one JSON object, either {"result": ...} or +// {"error": {"code": "...", "message": "..."}}, the envelope 3DForts' debug +// API used. A streaming request (`debug.log` with follow) is answered by one +// object per line instead. Methods named debug.* are the session's own; any +// other method is a command the app registered. +// +// `debug.hello` is frozen: every version answers it the same way, so a +// client can always learn the protocol and source of the app it reached +// before relying on anything else. +export module Crafter.Debug.Core:Protocol; +import std; +import :Value; + +export namespace Crafter::Debug { + // Bumped on any change to a request or reply a client relies on. + inline constexpr std::uint32_t protocolVersion = 1; + // The library's own version, which debug.hello reports. + inline constexpr std::string_view libraryVersion = "0.1.0-dev"; + // The QUIC ALPN: frozen with debug.hello, so a mismatch is reported by + // hello instead of failing the handshake. + inline constexpr std::string_view alpn = "crafter-debug/1"; + + // The error codes the session itself answers with; app commands add + // their own. + namespace Codes { + inline constexpr std::string_view unauthorized = "Unauthorized"; + inline constexpr std::string_view unknownMethod = "UnknownMethod"; + inline constexpr std::string_view invalidRequest = "InvalidRequest"; + inline constexpr std::string_view invalidParams = "InvalidParams"; + inline constexpr std::string_view timeout = "Timeout"; + inline constexpr std::string_view notPaused = "NotPaused"; + inline constexpr std::string_view pauseUnsupported = "PauseUnsupported"; + inline constexpr std::string_view noCapture = "NoCapture"; + inline constexpr std::string_view shuttingDown = "ShuttingDown"; + inline constexpr std::string_view exception = "Exception"; + inline constexpr std::string_view readOnly = "ReadOnly"; + inline constexpr std::string_view unknownPath = "UnknownPath"; + inline constexpr std::string_view conditionError = "ConditionError"; + } + + Value MakeReply(Value result); + Value MakeErrorReply(std::string_view code, std::string_view message); + + // Standard base64 with padding, for screenshots in a JSON reply. + std::string Base64Encode(std::span bytes); + std::optional> Base64Decode(std::string_view text); +} diff --git a/interfaces/Crafter.Debug.Core.cppm b/interfaces/Crafter.Debug.Core.cppm index 33eb239..7360b9a 100644 --- a/interfaces/Crafter.Debug.Core.cppm +++ b/interfaces/Crafter.Debug.Core.cppm @@ -9,3 +9,4 @@ export import :Value; export import :Reflect; export import :Codec; export import :Result; +export import :Protocol; diff --git a/interfaces/Crafter.Debug.cppm b/interfaces/Crafter.Debug.cppm index b69e9a1..290fa5c 100644 --- a/interfaces/Crafter.Debug.cppm +++ b/interfaces/Crafter.Debug.cppm @@ -3,17 +3,19 @@ // Runtime debugging for AI agents: what an app imports. Commands, watches // and samples (:Registry), the timeline with Log, Check and Emit -// (:Timeline), and the conditions waits and pauses use (:Condition), over -// the core shared with the client. The session follows in a later phase. +// (:Timeline), the conditions waits and pauses use (:Condition), and the +// session that serves them with Pump, Advance and pause control (:Session), +// over the core shared with the client. export module Crafter.Debug; export import Crafter.Debug.Core; export import :Registry; export import :Timeline; export import :Condition; +export import :Session; import std; export namespace Crafter::Debug { - std::string_view Version() { - return "0.0.0-dev"; + constexpr std::string_view Version() { + return libraryVersion; } } diff --git a/project.cpp b/project.cpp index 905ed69..83b4eb6 100644 --- a/project.cpp +++ b/project.cpp @@ -36,11 +36,14 @@ namespace { }; // Tests of the app library. - constexpr std::array libraryTests = { + constexpr std::array libraryTests = { "ShouldInvokeCommands", "ShouldResolveWatchPaths", "ShouldRecordTimeline", "ShouldEvaluateConditions", + "ShouldAnswerRequests", + "ShouldPauseAndStep", + "ShouldWaitTraceAndFollow", }; } @@ -59,16 +62,18 @@ extern "C" Configuration CrafterBuildProject(std::span a core.type = ConfigurationType::LibraryStatic; ApplyStandardArgs(core, args); { - std::array interfaces = { + std::array interfaces = { "interfaces/Crafter.Debug.Core-Value", "interfaces/Crafter.Debug.Core-Reflect", "interfaces/Crafter.Debug.Core-Codec", "interfaces/Crafter.Debug.Core-Result", + "interfaces/Crafter.Debug.Core-Protocol", "interfaces/Crafter.Debug.Core", }; - std::array implementations = { + std::array implementations = { "implementations/Crafter.Debug.Core-Value", "implementations/Crafter.Debug.Core-Codec", + "implementations/Crafter.Debug.Core-Protocol", }; core.GetInterfacesAndImplementations(interfaces, implementations); } @@ -94,16 +99,18 @@ extern "C" Configuration CrafterBuildProject(std::span a cfg.dependencies = { &core, &client }; { - std::array interfaces = { + std::array interfaces = { "interfaces/Crafter.Debug-Registry", "interfaces/Crafter.Debug-Timeline", "interfaces/Crafter.Debug-Condition", + "interfaces/Crafter.Debug-Session", "interfaces/Crafter.Debug", }; - std::array implementations = { + std::array implementations = { "implementations/Crafter.Debug-Registry", "implementations/Crafter.Debug-Timeline", "implementations/Crafter.Debug-Condition", + "implementations/Crafter.Debug-Session", }; cfg.GetInterfacesAndImplementations(interfaces, implementations); } diff --git a/tests/ShouldAnswerRequests/main.cpp b/tests/ShouldAnswerRequests/main.cpp new file mode 100644 index 0000000..744720a --- /dev/null +++ b/tests/ShouldAnswerRequests/main.cpp @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// The session answers requests the way a client relies on: a wrong token is +// Unauthorized, a mistyped method gets a suggestion, hello and status are +// answered at once, while calls, gets, sets and screenshots wait for the +// app's Pump and run there. A request whose timeout passes before that Pump +// is answered Timeout and is never run; a throwing handler is an Exception +// reply; an Async result arrives on the Pump it is ready; the agent's +// actions appear on the timeline; there is one Session per process; and +// requests still queued when it ends are answered ShuttingDown. +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& line) override { + std::lock_guard lock(mutex); + lines.push_back(line); + return true; + } + std::optional Final() { + std::lock_guard lock(mutex); + return final; + } + + private: + std::mutex mutex; + std::optional final; + std::vector lines; + }; + + Session* session = nullptr; + + std::shared_ptr Send(std::string_view method, Value params = {}, std::int64_t timeoutMs = 30'000) { + Value request = Value::MakeObject(); + request.Set("token", Value(session->Token())); + request.Set("method", Value(method)); + request.Set("params", std::move(params)); + request.Set("timeoutMs", Value(timeoutMs)); + auto exchange = std::make_shared(); + 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(); + } + + std::string MessageOf(FakeExchange& exchange) { + const auto reply = exchange.Final(); + return reply && reply->Find("error") ? reply->Find("error")->Find("message")->AsString() : std::string(); + } +} + +int main() { + Command("Player_Fire", "Fire.", [] { return std::string("bang"); }); + Command("Game_Crash", "Throws.", []() -> std::int32_t { throw std::runtime_error("boom"); }); + std::int32_t polls = 0; + Command("Sim_Hash", "Ready on the second poll.", [&]() -> Async { + return Async([&]() -> std::optional { + if (++polls < 2) return std::nullopt; + return 7; + }); + }); + std::int32_t hp = 100; + Watch("fort.hp", hp); + const std::int32_t level = 3; + Watch("level", level); + + std::shared_ptr leftOver; + { + Session owned({ .name = "test", .listen = false }); + session = &owned; + + auto wrongToken = std::make_shared(); + owned.Handle(Value::Parse(R"({"token": "nope", "method": "debug.status"})"), wrongToken); + Check(CodeOf(*wrongToken) == "Unauthorized", "a wrong token is refused"); + auto notObject = std::make_shared(); + owned.Handle(Value(3), notObject); + Check(CodeOf(*notObject) == "InvalidRequest", "a request that is not an object is refused"); + + const Value hello = ResultOf(*Send("debug.hello")); + Check(hello.Find("protocol")->AsInteger() == 1 && hello.Find("name")->AsString() == "test", "hello names the protocol and the instance"); + const Value status = ResultOf(*Send("debug.status")); + Check(status.Find("frame")->AsInteger() == 0 && !status.Find("paused")->AsBool() && !status.Find("pauseSupported")->AsBool(), "status is answered at once"); + const Value list = ResultOf(*Send("debug.list")); + Check(list.Find("commands")->Size() == 3 && list.Find("watches")->Size() == 5, "list has the commands and watches, the session's own included"); + + auto typo = Send("debug.stauts"); + Check(CodeOf(*typo) == "UnknownMethod" && MessageOf(*typo).find("did you mean 'debug.status'") != std::string::npos, "a mistyped method gets a suggestion"); + auto commandTypo = Send("Player_Fier"); + Check(CodeOf(*commandTypo) == "UnknownMethod" && MessageOf(*commandTypo).find("did you mean 'Player_Fire'") != std::string::npos, "a mistyped command gets a suggestion"); + + auto fire = Send("Player_Fire"); + Check(!fire->Final(), "a call waits for Pump"); + Pump(); + Check(ResultOf(*fire).AsString() == "bang", "Pump runs the call and replies"); + + auto get = Send("debug.get", Value::Parse(R"({"paths": ["fort.hp", "lev*", "nope"]})")); + Pump(); + const Value got = ResultOf(*get); + Check(got.Find("values")->Find("fort.hp")->AsInteger() == 100 && got.Find("values")->Find("level")->AsInteger() == 3 && got.Find("errors")->Find("nope") != nullptr, + "get reads paths and globs, and reports the ones it cannot"); + auto badGet = Send("debug.get", Value::Parse(R"({"pathz": []})")); + Pump(); + Check(CodeOf(*badGet) == "InvalidParams" && MessageOf(*badGet).find("did you mean 'paths'") != std::string::npos, "a mistyped parameter gets a suggestion"); + + auto set = Send("debug.set", Value::Parse(R"({"path": "fort.hp", "value": 5})")); + Pump(); + Check(hp == 5 && ResultOf(*set).Find("value")->AsInteger() == 5, "set writes on the Pump thread and replies with the new value"); + auto readOnly = Send("debug.set", Value::Parse(R"({"path": "level", "value": 5})")); + Pump(); + Check(CodeOf(*readOnly) == "ReadOnly", "setting a read-only watch is refused"); + + auto crash = Send("Game_Crash"); + Pump(); + Check(CodeOf(*crash) == "Exception" && MessageOf(*crash).find("boom") != std::string::npos, "a throwing handler is an Exception reply"); + + std::int32_t fired = 0; + Command("Count", "Counts.", [&] { ++fired; }); + auto late = Send("Count", {}, 1); + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + Pump(); + Check(CodeOf(*late) == "Timeout" && MessageOf(*late).find("not run") != std::string::npos && fired == 0, "a request past its timeout before Pump is never run"); + + auto hash = Send("Sim_Hash"); + Pump(); + Check(!hash->Final(), "an Async result is not ready on its first poll"); + Pump(); + Check(ResultOf(*hash).AsInteger() == 7, "an Async result is replied on the Pump it is ready"); + + auto noShot = Send("debug.screenshot"); + Pump(); + Check(CodeOf(*noShot) == "NoCapture", "a screenshot without a capture source says so"); + SetCapture([] { + return Async>([]() -> std::optional> { + return std::vector{ std::byte{ 0x89 }, std::byte{ 'P' }, std::byte{ 'N' }, std::byte{ 'G' } }; + }); + }); + auto shot = Send("debug.screenshot"); + Pump(); + const auto png = Base64Decode(ResultOf(*shot).Find("png")->AsString()); + Check(png && png->size() == 4 && (*png)[1] == std::byte{ 'P' }, "a screenshot's PNG comes back as base64"); + + auto agent = Send("debug.log", Value::Parse(R"({"kinds": ["agent"], "last": 100})")); + const Value entries = *ResultOf(*agent).Find("entries"); + Check(std::ranges::any_of(entries.Items(), [](const Value& entry) { return entry.Find("message")->AsString().starts_with("Player_Fire"); }), + "the agent's calls are on the timeline"); + + bool threw = false; + try { + Session second({ .listen = false }); + } catch (const std::logic_error&) { + threw = true; + } + Check(threw, "a second Session is refused"); + + leftOver = Send("Player_Fire"); + } + Check(CodeOf(*leftOver) == "ShuttingDown", "a request still queued when the Session ends is answered ShuttingDown"); + + return failures == 0 ? 0 : 1; +} diff --git a/tests/ShouldPauseAndStep/main.cpp b/tests/ShouldPauseAndStep/main.cpp new file mode 100644 index 0000000..f17aafd --- /dev/null +++ b/tests/ShouldPauseAndStep/main.cpp @@ -0,0 +1,123 @@ +// 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 Final() { + std::lock_guard lock(mutex); + return final; + } + + private: + std::mutex mutex; + std::optional final; + }; + + Session* session = nullptr; + + std::shared_ptr 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(); + 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"); + Send("debug.resume"); + + 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; +} diff --git a/tests/ShouldResolveWatchPaths/main.cpp b/tests/ShouldResolveWatchPaths/main.cpp index 78ea2c2..23a95fd 100644 --- a/tests/ShouldResolveWatchPaths/main.cpp +++ b/tests/ShouldResolveWatchPaths/main.cpp @@ -57,16 +57,16 @@ int main() { Check(!registry().Set("physics.gravity", Value(-3.5)) && gravity == -3.5F, "a set writes the variable"); const auto bounded = registry().Set("physics.gravity", Value(10)); - Check(bounded && bounded->find("outside") != std::string::npos && gravity == -3.5F, "a set outside the bounds is refused"); - Check(registry().Set("build", Value(8)) == std::optional("'build' is read-only") && build == 7, "a const variable's watch is read-only"); + Check(bounded && bounded->code == "InvalidParams" && bounded->message.find("outside") != std::string::npos && gravity == -3.5F, "a set outside the bounds is refused"); + Check(registry().Set("build", Value(8))->code == "ReadOnly" && build == 7, "a const variable's watch is read-only"); Check(!registry().Set("bodies[0].pos.x", Value(9)) && bodies[0].pos.x == 9 && bodies[0].pos.y == 1, "a set into a member writes back the whole value"); const auto wrongType = registry().Set("physics.gravity", Value("down")); - Check(wrongType && wrongType->find("expected a number") != std::string::npos, "a set of the wrong type is refused"); + Check(wrongType && wrongType->message.find("expected a number") != std::string::npos, "a set of the wrong type is refused"); Check(!registry().Set("lives", Value(5)) && lives == 5, "a computed watch sets through its setter"); const auto negative = registry().Set("lives", Value(-1)); - Check(negative && negative->find("lives cannot be negative") != std::string::npos && lives == 5, "a setter can refuse"); - Check(registry().Set("score", Value(1)) == std::optional("'score' is read-only"), "a getter-only watch is read-only"); + Check(negative && negative->message.find("lives cannot be negative") != std::string::npos && lives == 5, "a setter can refuse"); + Check(registry().Set("score", Value(1))->code == "ReadOnly", "a getter-only watch is read-only"); const auto missing = registry().Resolve("physics.gravty"); Check(!missing.Ok() && missing.error.find("did you mean 'physics.gravity'") != std::string::npos, "an unknown path gets a suggestion"); diff --git a/tests/ShouldWaitTraceAndFollow/main.cpp b/tests/ShouldWaitTraceAndFollow/main.cpp new file mode 100644 index 0000000..60dbe43 --- /dev/null +++ b/tests/ShouldWaitTraceAndFollow/main.cpp @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// Waits, traces and log follows: `wait --frames` and a condition reply on the +// Pump that satisfies them, a condition that cannot be decided replies with +// why, an event wakes its waiter from the emitting thread without waiting +// for Pump, and a timeout says what the condition's paths were. A trace +// collects a value over frames with its stats, or a sample's history. The +// log filters by level, and a follower receives new matching entries in +// order, is dropped when it cannot keep up, and ends when its time is up. +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: + std::atomic refuse = false; + + void Reply(const Value& reply) override { + std::lock_guard lock(mutex); + if (!final) final = reply; + } + bool Stream(const Value& line) override { + if (refuse) return false; + std::lock_guard lock(mutex); + lines.push_back(line); + return true; + } + std::optional Final() { + std::lock_guard lock(mutex); + return final; + } + std::vector Lines() { + std::lock_guard lock(mutex); + return lines; + } + + private: + std::mutex mutex; + std::optional final; + std::vector lines; + }; + + Session* session = nullptr; + + std::shared_ptr Send(std::string_view method, std::string_view params = "{}", std::int64_t timeoutMs = 30'000) { + Value request = Value::MakeObject(); + request.Set("token", Value(session->Token())); + request.Set("method", Value(method)); + request.Set("params", Value::Parse(params)); + request.Set("timeoutMs", Value(timeoutMs)); + auto exchange = std::make_shared(); + 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(); + } + + std::string MessageOf(FakeExchange& exchange) { + const auto reply = exchange.Final(); + return reply && reply->Find("error") ? reply->Find("error")->Find("message")->AsString() : std::string(); + } +} + +int main() { + std::int32_t score = 0; + Watch("score", score); + Session owned({ .name = "wait", .listen = false }); + session = &owned; + + auto frames = Send("debug.wait", R"({"frames": 3})"); + Pump(); + Pump(); + Check(!frames->Final(), "wait --frames 3 has not replied after two frames"); + Pump(); + Check(ResultOf(*frames).Find("frame")->AsInteger() == 3, "wait --frames 3 replies on the third"); + + auto condition = Send("debug.wait", R"({"condition": "score >= 2"})"); + for (std::int32_t i = 0; i < 5 && !condition->Final(); ++i) { + ++score; + Pump(); + } + Check(score == 2 && CodeOf(*condition).empty(), "a condition replies on the Pump it holds"); + auto broken = Send("debug.wait", R"({"condition": "missing > 1"})"); + Pump(); + Check(CodeOf(*broken) == "ConditionError" && MessageOf(*broken).find("no watch or sample 'missing'") != std::string::npos, "an undecidable condition says why"); + + auto event = Send("debug.wait", R"({"event": "Boom"})"); + std::thread([] { Emit("Boom", Value::Parse(R"({"power": 9})")); }).join(); + Check(event->Final().has_value() && ResultOf(*event).Find("payload")->Find("power")->AsInteger() == 9, "an event wakes its waiter without waiting for Pump"); + + auto slow = Send("debug.wait", R"({"condition": "score > 100"})", 1); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + Pump(); + Check(CodeOf(*slow) == "Timeout" && MessageOf(*slow).find("score is 2") != std::string::npos, "a timeout reports the condition's paths"); + Check(CodeOf(*Send("debug.wait", R"({"frames": 1, "paused": true})")) == "InvalidParams", "waiting for two things at once is refused"); + + auto trace = Send("debug.trace", R"({"paths": ["score"], "frames": 4})"); + for (std::int32_t i = 0; i < 4; ++i) { + score += 10; + Pump(); + } + const Value traced = ResultOf(*trace); + const Value* series = traced.Find("series") ? traced.Find("series")->Find("score") : nullptr; + Check(series != nullptr && series->Find("values")->Size() == 4 && series->Find("stats")->Find("min")->AsNumber() == 12 + && series->Find("stats")->Find("max")->AsNumber() == 42 && series->Find("stats")->Find("changes")->AsInteger() == 3, + "a trace collects a value per frame with its stats"); + + for (std::int32_t i = 1; i <= 3; ++i) { + Sample("residual", 0.5 * i); + Pump(); + } + auto history = Send("debug.trace", R"({"paths": ["residual"], "history": true})"); + Pump(); + const Value historyReply = ResultOf(*history); + const Value* samples = historyReply.Find("series") ? historyReply.Find("series")->Find("residual") : nullptr; + Check(samples != nullptr && samples->Find("values")->Size() == 3 && samples->Find("stats")->Find("last")->AsNumber() == 1.5, "a trace can read a sample's history"); + + Log("net", "hello {}", 1); + LogWarn("net", "careful"); + const Value warnings = ResultOf(*Send("debug.log", R"({"level": "warn"})")); + Check(warnings.Find("entries")->Size() == 1 && warnings.Find("entries")->Items()[0].Find("message")->AsString() == "careful", "the log filters by level"); + Check(CodeOf(*Send("debug.log", R"({"level": "loud"})")) == "InvalidParams", "an unknown level is refused"); + + auto follow = Send("debug.log", R"({"follow": true, "category": "game"})"); + Log("game", "one"); + Log("other", "skip"); + Log("game", "two"); + const std::vector lines = follow->Lines(); + Check(lines.size() == 2 && lines[1].Find("entry")->Find("message")->AsString() == "two", "a follower receives new matching entries, in order"); + follow->refuse = true; + Log("game", "three"); + Check(ResultOf(*Send("debug.status")).Find("following")->AsInteger() == 0, "a follower that cannot keep up is dropped"); + + auto brief = Send("debug.log", R"({"follow": true, "forMs": 1})"); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + Pump(); + Check(brief->Final().has_value() && ResultOf(*brief).Find("ended") != nullptr, "a follow ends when its time is up"); + + return failures == 0 ? 0 : 1; +}