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; +}