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 <noreply@anthropic.com>
308 lines
13 KiB
C++
308 lines
13 KiB
C++
// SPDX-License-Identifier: LGPL-3.0-only
|
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
|
|
|
module Crafter.Debug:Registry_impl;
|
|
import std;
|
|
import Crafter.Debug.Core;
|
|
import :Registry;
|
|
|
|
using namespace Crafter::Debug;
|
|
using namespace Crafter::Debug::Detail;
|
|
|
|
namespace {
|
|
std::atomic<std::uint64_t> frame{ 0 };
|
|
|
|
// Points kept per sample path, enough for `trace` over a few seconds of
|
|
// frames without an app logging every frame growing without bound.
|
|
constexpr std::size_t sampleHistory = 512;
|
|
|
|
bool ValidName(std::string_view name) {
|
|
return !name.empty() && std::ranges::none_of(name, [](char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r'; });
|
|
}
|
|
|
|
// The registered name that `path` starts with, longest first, ending at a
|
|
// step boundary: "bodies" for "bodies[3].pos", not "bod".
|
|
template<class Map>
|
|
auto LongestPrefix(const Map& map, std::string_view path) -> decltype(map.end()) {
|
|
auto best = map.end();
|
|
std::size_t bestLength = 0;
|
|
for (auto it = map.begin(); it != map.end(); ++it) {
|
|
const std::string_view name = it->first;
|
|
if (!path.starts_with(name)) continue;
|
|
if (path.size() > name.size() && path[name.size()] != '.' && path[name.size()] != '[') continue;
|
|
if (name.size() >= bestLength) {
|
|
best = it;
|
|
bestLength = name.size();
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
std::vector<std::string_view> MemberNames(const Value& object) {
|
|
std::vector<std::string_view> names;
|
|
for (const auto& [key, member] : object.Members()) names.push_back(key);
|
|
return names;
|
|
}
|
|
|
|
std::string JoinNames(std::span<const std::string_view> names) {
|
|
std::string joined;
|
|
for (const std::string_view name : names) {
|
|
if (!joined.empty()) joined += ", ";
|
|
joined += name;
|
|
}
|
|
return joined;
|
|
}
|
|
|
|
// Follows `rest` (".name" and "[index]" steps) from `root`. `where` names
|
|
// the root in errors. Returns nullptr and sets `error` when a step fails.
|
|
template<class V>
|
|
V* Walk(V& root, std::string_view rest, std::string where, std::string& error) {
|
|
V* current = &root;
|
|
while (!rest.empty()) {
|
|
if (rest.front() == '.') {
|
|
rest.remove_prefix(1);
|
|
const std::size_t end = std::min(rest.find('.'), rest.find('['));
|
|
const std::string_view name = rest.substr(0, end);
|
|
rest.remove_prefix(name.size());
|
|
if (!current->IsObject()) {
|
|
error = std::format("'{}' is {}, so it has no member '{}'", where, KindName(current->Kind()), name);
|
|
return nullptr;
|
|
}
|
|
V* next = current->Find(name);
|
|
if (next == nullptr) {
|
|
const std::vector<std::string_view> names = MemberNames(*current);
|
|
const auto suggestion = Suggest(name, names);
|
|
error = suggestion ? std::format("'{}' has no member '{}'; did you mean '{}'?", where, name, *suggestion)
|
|
: std::format("'{}' has no member '{}' (members: {})", where, name, JoinNames(names));
|
|
return nullptr;
|
|
}
|
|
where += '.';
|
|
where += name;
|
|
current = next;
|
|
} else if (rest.front() == '[') {
|
|
const std::size_t close = rest.find(']');
|
|
std::size_t index = 0;
|
|
const std::string_view digits = rest.substr(1, close == std::string_view::npos ? std::string_view::npos : close - 1);
|
|
const auto [end, failure] = std::from_chars(digits.data(), digits.data() + digits.size(), index);
|
|
if (close == std::string_view::npos || failure != std::errc() || end != digits.data() + digits.size()) {
|
|
error = std::format("'{}' is followed by a malformed index: '{}'", where, rest);
|
|
return nullptr;
|
|
}
|
|
rest.remove_prefix(close + 1);
|
|
if (!current->IsArray()) {
|
|
error = std::format("'{}' is {}, not an array", where, KindName(current->Kind()));
|
|
return nullptr;
|
|
}
|
|
if (index >= current->Size()) {
|
|
error = std::format("'{}' has {} items, so [{}] is out of range", where, current->Size(), index);
|
|
return nullptr;
|
|
}
|
|
where += std::format("[{}]", index);
|
|
current = ¤t->Items()[index];
|
|
} else {
|
|
error = std::format("cannot read '{}' after '{}'", rest, where);
|
|
return nullptr;
|
|
}
|
|
}
|
|
return current;
|
|
}
|
|
}
|
|
|
|
std::uint64_t Crafter::Debug::Detail::CurrentFrame() {
|
|
return frame.load(std::memory_order_relaxed);
|
|
}
|
|
|
|
void Crafter::Debug::Detail::SetFrame(std::uint64_t value) {
|
|
frame.store(value, std::memory_order_relaxed);
|
|
}
|
|
|
|
bool Crafter::Debug::Detail::GlobMatch(std::string_view glob, std::string_view text) {
|
|
std::size_t g = 0;
|
|
std::size_t t = 0;
|
|
std::size_t star = std::string_view::npos;
|
|
std::size_t mark = 0;
|
|
while (t < text.size()) {
|
|
if (g < glob.size() && (glob[g] == '?' || glob[g] == text[t])) {
|
|
++g;
|
|
++t;
|
|
} else if (g < glob.size() && glob[g] == '*') {
|
|
star = g++;
|
|
mark = t;
|
|
} else if (star != std::string_view::npos) {
|
|
g = star + 1;
|
|
t = ++mark;
|
|
} else {
|
|
return false;
|
|
}
|
|
}
|
|
while (g < glob.size() && glob[g] == '*') ++g;
|
|
return g == glob.size();
|
|
}
|
|
|
|
std::string Crafter::Debug::Detail::Joined(std::string_view prefix, std::string_view name) {
|
|
if (prefix.empty()) return std::string(name);
|
|
if (name.empty()) return std::string(prefix);
|
|
return std::format("{}.{}", prefix, name);
|
|
}
|
|
|
|
Registry& Registry::Instance() {
|
|
static Registry registry;
|
|
return registry;
|
|
}
|
|
|
|
std::uint64_t Registry::NewOwner() {
|
|
return nextOwner.fetch_add(1, std::memory_order_relaxed);
|
|
}
|
|
|
|
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<const CommandEntry>(std::move(entry)));
|
|
}
|
|
|
|
void Registry::AddWatch(WatchEntry entry) {
|
|
if (!ValidName(entry.path)) throw std::invalid_argument(std::format("Crafter.Debug: '{}' is not a watch path: it must be non-empty, without whitespace", entry.path));
|
|
std::string path = entry.path;
|
|
std::lock_guard lock(mutex);
|
|
watches.insert_or_assign(std::move(path), std::make_shared<const WatchEntry>(std::move(entry)));
|
|
}
|
|
|
|
void Registry::AddSample(std::string_view path, Value value, std::uint64_t sampleFrame, std::uint64_t owner) {
|
|
std::lock_guard lock(mutex);
|
|
auto found = samples.find(path);
|
|
if (found == samples.end()) found = samples.emplace(std::string(path), Series{}).first;
|
|
Series& series = found->second;
|
|
series.owner = owner;
|
|
series.points.push_back({ sampleFrame, std::move(value) });
|
|
if (series.points.size() > sampleHistory) series.points.pop_front();
|
|
}
|
|
|
|
void Registry::RemoveOwner(std::uint64_t owner) {
|
|
std::lock_guard lock(mutex);
|
|
std::erase_if(commands, [&](const auto& item) { return item.second->owner == owner; });
|
|
std::erase_if(watches, [&](const auto& item) { return item.second->owner == owner; });
|
|
std::erase_if(samples, [&](const auto& item) { return item.second.owner == owner; });
|
|
}
|
|
|
|
std::shared_ptr<const CommandEntry> Registry::FindCommand(std::string_view name) const {
|
|
std::lock_guard lock(mutex);
|
|
const auto found = commands.find(name);
|
|
return found == commands.end() ? nullptr : found->second;
|
|
}
|
|
|
|
std::vector<std::shared_ptr<const CommandEntry>> Registry::Commands(std::string_view glob) const {
|
|
std::lock_guard lock(mutex);
|
|
std::vector<std::shared_ptr<const CommandEntry>> matched;
|
|
for (const auto& [name, entry] : commands) {
|
|
if (GlobMatch(glob, name)) matched.push_back(entry);
|
|
}
|
|
return matched;
|
|
}
|
|
|
|
std::vector<std::shared_ptr<const WatchEntry>> Registry::Watches(std::string_view glob) const {
|
|
std::lock_guard lock(mutex);
|
|
std::vector<std::shared_ptr<const WatchEntry>> matched;
|
|
for (const auto& [path, entry] : watches) {
|
|
if (GlobMatch(glob, path)) matched.push_back(entry);
|
|
}
|
|
return matched;
|
|
}
|
|
|
|
std::vector<std::string> Registry::SamplePaths(std::string_view glob) const {
|
|
std::lock_guard lock(mutex);
|
|
std::vector<std::string> matched;
|
|
for (const auto& [path, series] : samples) {
|
|
if (GlobMatch(glob, path)) matched.push_back(path);
|
|
}
|
|
return matched;
|
|
}
|
|
|
|
std::vector<SamplePoint> Registry::SampleHistory(std::string_view path) const {
|
|
std::lock_guard lock(mutex);
|
|
const auto found = samples.find(path);
|
|
if (found == samples.end()) return {};
|
|
return std::vector<SamplePoint>(found->second.points.begin(), found->second.points.end());
|
|
}
|
|
|
|
Resolved Registry::Resolve(std::string_view path) const {
|
|
std::shared_ptr<const WatchEntry> watch;
|
|
std::optional<SamplePoint> latest;
|
|
std::string base;
|
|
{
|
|
std::lock_guard lock(mutex);
|
|
const auto foundWatch = LongestPrefix(watches, path);
|
|
const auto foundSample = LongestPrefix(samples, path);
|
|
const std::size_t watchLength = foundWatch == watches.end() ? 0 : foundWatch->first.size();
|
|
const std::size_t sampleLength = foundSample == samples.end() ? 0 : foundSample->first.size();
|
|
if (foundWatch != watches.end() && watchLength >= sampleLength) {
|
|
watch = foundWatch->second;
|
|
base = foundWatch->first;
|
|
} else if (foundSample != samples.end() && !foundSample->second.points.empty()) {
|
|
latest = foundSample->second.points.back();
|
|
base = foundSample->first;
|
|
}
|
|
}
|
|
if (!watch && !latest) {
|
|
const auto suggestion = SuggestPath(path);
|
|
return { Value(), suggestion ? std::format("no watch or sample '{}'; did you mean '{}'?", path, *suggestion)
|
|
: std::format("no watch or sample '{}'; list them with: crafter-debug get '*'", path) };
|
|
}
|
|
Value root = watch ? watch->get() : std::move(latest->value);
|
|
std::string error;
|
|
const Value* reached = Walk(std::as_const(root), path.substr(base.size()), base, error);
|
|
if (reached == nullptr) return { Value(), error };
|
|
return { *reached, {} };
|
|
}
|
|
|
|
std::optional<Error> Registry::Set(std::string_view path, const Value& value) const {
|
|
std::shared_ptr<const WatchEntry> watch;
|
|
{
|
|
std::lock_guard lock(mutex);
|
|
const auto found = LongestPrefix(watches, path);
|
|
if (found != watches.end()) watch = found->second;
|
|
}
|
|
if (!watch) {
|
|
const auto suggestion = SuggestPath(path);
|
|
return Error{ "UnknownPath", suggestion ? std::format("no watch '{}'; did you mean '{}'?", path, *suggestion) : std::format("no 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());
|
|
std::optional<std::string> 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<std::string> Registry::SuggestCommand(std::string_view name) const {
|
|
std::lock_guard lock(mutex);
|
|
std::vector<std::string_view> names;
|
|
for (const auto& [command, entry] : commands) names.push_back(command);
|
|
if (const auto suggestion = Suggest(name, names)) return std::string(*suggestion);
|
|
return std::nullopt;
|
|
}
|
|
|
|
std::optional<std::string> Registry::SuggestPath(std::string_view path) const {
|
|
std::lock_guard lock(mutex);
|
|
std::vector<std::string_view> names;
|
|
for (const auto& [watch, entry] : watches) names.push_back(watch);
|
|
for (const auto& [sample, series] : samples) names.push_back(sample);
|
|
if (const auto suggestion = Suggest(path, names)) return std::string(*suggestion);
|
|
return std::nullopt;
|
|
}
|
|
|
|
Group::Group(std::string prefix) : prefix(std::move(prefix)), owner(Registry::Instance().NewOwner()) {}
|
|
|
|
Group::~Group() {
|
|
Registry::Instance().RemoveOwner(owner);
|
|
}
|