From f8024b7a43562cb16030cfe2b6e844652ba010a3 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Sun, 1 Nov 2026 03:07:09 +0100 Subject: [PATCH 01/10] feat(cli): reach instances over QUIC crafter-debug had a placeholder transport that reported every instance unreachable. Cli::ConnectQuic dials 127.0.0.1 at the port in the discovery record on the first call, keeps the connection for the next, and dials again after one is lost. Each call opens a stream, sends the request with a FIN, and reads lines: one with a result or an error is the reply, anything before it a stream line for onLine, which runs on the calling thread so a slow terminal never stalls msquic's worker. Past the deadline the stream is aborted and the call times out. The handshake gives up after 3 s, and keep-alives every 2 s hold a quiet follow or a long wait open under the 10 s idle timeout. main starts a pool with one worker, which Network reports some failures on, and ends with _Exit once Run has closed its connections, since msquic's teardown at exit waits on connections. Co-Authored-By: Claude Opus 5.5 --- implementations/Crafter.Debug.Cli-Quic.cpp | 163 +++++++++++++++++++++ implementations/main.cpp | 34 +++-- interfaces/Crafter.Debug.Cli-Quic.cppm | 16 ++ interfaces/Crafter.Debug.Cli.cppm | 4 +- project.cpp | 6 +- 5 files changed, 204 insertions(+), 19 deletions(-) create mode 100644 implementations/Crafter.Debug.Cli-Quic.cpp create mode 100644 interfaces/Crafter.Debug.Cli-Quic.cppm diff --git a/implementations/Crafter.Debug.Cli-Quic.cpp b/implementations/Crafter.Debug.Cli-Quic.cpp new file mode 100644 index 0000000..2ac607d --- /dev/null +++ b/implementations/Crafter.Debug.Cli-Quic.cpp @@ -0,0 +1,163 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +module Crafter.Debug.Cli:Quic_impl; +import std; +import Crafter.Network; +import Crafter.Debug.Core; +import :Client; +import :Quic; + +using namespace Crafter::Debug; +using namespace Crafter::Debug::Cli; +using Crafter::ClientQUIC; +using Crafter::QUICClientCredentials; +using Crafter::QUICStream; +using Clock = std::chrono::steady_clock; + +namespace { + QUICClientCredentials Credentials() { + QUICClientCredentials credentials; + // The app serves a per-user development certificate. What + // authenticates is the token in each request, which only this user + // can read. + credentials.insecureNoServerValidation = true; + // A port nobody answers on fails in seconds, not at msquic's 10. + credentials.settings.handshakeIdleTimeoutMs = 3'000; + // Keep-alives under the idle timeout hold a quiet `log --follow` or + // a long `wait` open; an app that died is noticed within the timeout. + credentials.settings.idleTimeoutMs = 10'000; + credentials.settings.keepAliveIntervalMs = 2'000; + return credentials; + } + + // What a request's callbacks, on msquic's worker, hand the thread that + // waits for its reply. + struct Inbox { + std::mutex mutex; + std::condition_variable changed; + std::string partial; + std::deque lines; + bool ended = false; + bool clean = false; + + void Add(std::span data) { + { + std::lock_guard lock(mutex); + partial.append(data.data(), data.size()); + std::size_t start = 0; + for (std::size_t newline = partial.find('\n'); newline != std::string::npos; newline = partial.find('\n', start)) { + lines.emplace_back(partial, start, newline - start); + start = newline + 1; + } + partial.erase(0, start); + } + changed.notify_all(); + } + + void End(bool wasClean) { + { + std::lock_guard lock(mutex); + ended = true; + clean = wasClean; + } + changed.notify_all(); + } + }; + + std::string Abbreviated(std::string_view text) { + return text.size() <= 200 ? std::string(text) : std::format("{}...", text.substr(0, 200)); + } + + class QuicConnection final : public Connection { + public: + explicit QuicConnection(std::uint16_t port) : port(port) {} + + std::expected Call(const Value& request, std::chrono::milliseconds timeout, + const std::function& onLine) override { + const Clock::time_point deadline = Clock::now() + timeout; + if (!client) { + try { + client = std::make_unique(std::string("127.0.0.1"), port, std::string(alpn), Credentials()); + } catch (const std::exception& error) { + return std::unexpected(CallError{ CallError::Kind::Unreachable, error.what() }); + } + } + auto result = Send(request, deadline, onLine); + // A lost connection is not reused: the next call dials again. + // After Send, whose stream must go before its connection. + if (!result && result.error().kind == CallError::Kind::Unreachable) client.reset(); + return result; + } + + private: + std::uint16_t port; + std::unique_ptr client; + + std::expected Send(const Value& request, Clock::time_point deadline, + const std::function& onLine) { + const auto inbox = std::make_shared(); + QUICStream stream; + try { + stream = client->OpenStream(); + } catch (const std::exception& error) { + return std::unexpected(CallError{ CallError::Kind::Unreachable, error.what() }); + } + stream.OnRecieve([inbox](std::span data) { inbox->Add(data); }, + [inbox](bool clean) { inbox->End(clean); }); + std::string text = request.Dump(); + text += '\n'; + try { + stream.SendSync(text.data(), static_cast(text.size()), true); + } catch (const std::exception& error) { + return std::unexpected(CallError{ CallError::Kind::Unreachable, error.what() }); + } + + std::unique_lock lock(inbox->mutex); + for (;;) { + if (!inbox->lines.empty()) { + const std::string line = std::move(inbox->lines.front()); + inbox->lines.pop_front(); + // Not under the lock: onLine prints, and the callbacks + // that fill the inbox must not wait for a terminal. + lock.unlock(); + Value value; + try { + value = Value::Parse(line); + } catch (const ParseError& error) { + stream.Abort(); + return std::unexpected(CallError{ CallError::Kind::Broken, std::format("{} in {}", error.what(), Abbreviated(line)) }); + } + if (!value.IsObject()) { + stream.Abort(); + return std::unexpected(CallError{ CallError::Kind::Broken, std::format("not an object: {}", Abbreviated(line)) }); + } + // The reply is the object with a result or an error; + // anything before it is a stream line. + if (value.Find("result") != nullptr || value.Find("error") != nullptr) return value; + if (onLine && !onLine(value)) { + stream.Abort(); + Value stopped = Value::MakeObject(); + stopped.Set("ended", Value("stopped by the client")); + return MakeReply(std::move(stopped)); + } + lock.lock(); + continue; + } + if (inbox->ended) { + if (inbox->clean) return std::unexpected(CallError{ CallError::Kind::Broken, "the app ended the request without a reply" }); + return std::unexpected(CallError{ CallError::Kind::Unreachable, "the connection to the app was lost" }); + } + if (inbox->changed.wait_until(lock, deadline) == std::cv_status::timeout && inbox->lines.empty() && !inbox->ended) { + lock.unlock(); + stream.Abort(); + return std::unexpected(CallError{ CallError::Kind::TimedOut, "no reply in time" }); + } + } + } + }; +} + +std::unique_ptr Crafter::Debug::Cli::ConnectQuic(const InstanceRecord& instance) { + return std::make_unique(instance.port); +} diff --git a/implementations/main.cpp b/implementations/main.cpp index 011d3d5..fb59d17 100644 --- a/implementations/main.cpp +++ b/implementations/main.cpp @@ -1,26 +1,30 @@ // SPDX-License-Identifier: LGPL-3.0-only // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® -// crafter-debug: Cli::Run with the real transport. +// crafter-debug: Cli::Run with the QUIC transport. import std; +import Crafter.Thread; import Crafter.Debug.Core; import Crafter.Debug.Cli; using namespace Crafter::Debug; -namespace { - // Until the QUIC transport lands: every call reports it unreachable. - class Unbuilt : public Cli::Connection { - public: - std::expected Call(const Value&, std::chrono::milliseconds, const std::function&) override { - return std::unexpected(Cli::CallError{ Cli::CallError::Kind::Unreachable, "this client was built without its transport" }); - } - }; -} - int main(int argc, char** argv) { + // Crafter.Network reports some failures through the pool. One request + // at a time needs one worker. + Crafter::ThreadPool::Start(2); const std::vector args(argv + 1, argv + argc); - Cli::Environment environment; - environment.connect = [](const InstanceRecord&) -> std::unique_ptr { return std::make_unique(); }; - environment.haveLldb = [] { return std::filesystem::exists("/app/bin/lldb") || std::filesystem::exists("/usr/bin/lldb"); }; - return Cli::Run(args, environment); + std::int32_t code = 0; + { + Cli::Environment environment; + environment.connect = Cli::ConnectQuic; + environment.haveLldb = [] { return std::filesystem::exists("/app/bin/lldb") || std::filesystem::exists("/usr/bin/lldb"); }; + // Run closes its connections before it returns. + code = Cli::Run(args, environment); + } + std::cout.flush(); + std::cerr.flush(); + std::fflush(nullptr); + // Not through exit: msquic's teardown at exit waits for connections, + // and the pool's workers would need stopping first. + std::_Exit(code); } diff --git a/interfaces/Crafter.Debug.Cli-Quic.cppm b/interfaces/Crafter.Debug.Cli-Quic.cppm new file mode 100644 index 0000000..6018f8c --- /dev/null +++ b/interfaces/Crafter.Debug.Cli-Quic.cppm @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// The client's transport: a Connection over QUIC to an instance's port on +// 127.0.0.1, one stream per request. The library's :Transport describes the +// wire. +export module Crafter.Debug.Cli:Quic; +import std; +import Crafter.Debug.Core; +import :Client; + +export namespace Crafter::Debug::Cli { + // Connects on the first call and keeps the connection for later ones, + // reconnecting after one is lost. Needs Crafter.Thread's pool running. + std::unique_ptr ConnectQuic(const InstanceRecord& instance); +} diff --git a/interfaces/Crafter.Debug.Cli.cppm b/interfaces/Crafter.Debug.Cli.cppm index 0d33b6c..8e83d7a 100644 --- a/interfaces/Crafter.Debug.Cli.cppm +++ b/interfaces/Crafter.Debug.Cli.cppm @@ -1,6 +1,8 @@ // SPDX-License-Identifier: LGPL-3.0-only // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® -// The crafter-debug client, as a library so tests can run it in process. +// The crafter-debug client, as a library so tests can run it in process: +// the program (:Client) and its QUIC transport (:Quic). export module Crafter.Debug.Cli; export import :Client; +export import :Quic; diff --git a/project.cpp b/project.cpp index 422d9d8..69c9b21 100644 --- a/project.cpp +++ b/project.cpp @@ -172,7 +172,7 @@ extern "C" Configuration CrafterBuildProject(std::span a cli.mtune = cfg.mtune; cli.sysroot = cfg.sysroot; cli.debug = cfg.debug; - cli.dependencies = { &core }; + cli.dependencies = { &core, network }; client.dependencies = { &cli }; { @@ -196,8 +196,8 @@ extern "C" Configuration CrafterBuildProject(std::span a core.GetInterfacesAndImplementations(interfaces, implementations); } { - std::array interfaces = { "interfaces/Crafter.Debug.Cli-Client", "interfaces/Crafter.Debug.Cli" }; - std::array implementations = { "implementations/Crafter.Debug.Cli-Client" }; + std::array interfaces = { "interfaces/Crafter.Debug.Cli-Client", "interfaces/Crafter.Debug.Cli-Quic", "interfaces/Crafter.Debug.Cli" }; + std::array implementations = { "implementations/Crafter.Debug.Cli-Client", "implementations/Crafter.Debug.Cli-Quic" }; cli.GetInterfacesAndImplementations(interfaces, implementations); } { From fbb5809aea1fbb5770db7d42a9ae3e2a9771e02a Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Sun, 1 Nov 2026 03:07:09 +0100 Subject: [PATCH 02/10] test(transport): drive a listening session over loopback ShouldServeOverQuic runs a Session that listens for real and reaches it with the client's own transport. It covers: - the discovery record, and its removal when the Session ends; - status, call, a mistyped command, get and set, and log --follow streaming as entries are logged and ending on time; - six clients at once, and eight streams at once on one connection; - a wrong token, a request that isn't JSON, and one over 1 MiB, each answered with an error; - ending the Session with a follower waiting, which is told "shutting down" and is not sent a backlog it asked none of; - a client finding the port dead in milliseconds. The shutdown reply went missing in 4 runs of 30 until Crafter.Network's ~ClientQUIC let stopped streams finish before closing; 50 of 50 pass since. Co-Authored-By: Claude Opus 5.5 --- project.cpp | 1 + tests/ShouldServeOverQuic/main.cpp | 264 +++++++++++++++++++++++++++++ 2 files changed, 265 insertions(+) create mode 100644 tests/ShouldServeOverQuic/main.cpp diff --git a/project.cpp b/project.cpp index 69c9b21..be39bc3 100644 --- a/project.cpp +++ b/project.cpp @@ -232,6 +232,7 @@ extern "C" Configuration CrafterBuildProject(std::span a for (const std::string_view test : coreTests) cfg.AddTest(test).Dependencies({ &core }); for (const std::string_view test : libraryTests) cfg.AddTest(test).Dependencies({ &cfg }); cfg.AddTest("ShouldRunClientCommands").Dependencies({ &cfg, &cli }); + cfg.AddTest("ShouldServeOverQuic").Dependencies({ &cfg, &cli }); } return cfg; } diff --git a/tests/ShouldServeOverQuic/main.cpp b/tests/ShouldServeOverQuic/main.cpp new file mode 100644 index 0000000..e9d6096 --- /dev/null +++ b/tests/ShouldServeOverQuic/main.cpp @@ -0,0 +1,264 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// The QUIC transport end to end: a Session that listens for real, reached +// over loopback by the client's own code, as crafter-debug reaches it. What +// the in-process tests can't see: +// - discovery lists the instance with its port, token, protocol, source and +// client, and drops it once the Session is gone; +// - status, call, get and set work through the client, and log --follow +// streams lines as they are logged, then ends; +// - a wrong token is refused, a request that isn't JSON is answered, and +// one over 1 MiB is answered with an error instead of being buffered; +// - requests in parallel, on streams of one connection and on separate +// connections, each get their own reply; +// - destroying the Session answers a waiting follower and closes promptly +// with a client still connected, and a client then finds the port dead +// within seconds. +#include +#include +import std; +import Crafter.Thread; +import Crafter.Network; +import Crafter.Debug; +import Crafter.Debug.Cli; +using namespace Crafter::Debug; +namespace fs = std::filesystem; +using namespace std::chrono_literals; + +namespace { + std::int32_t failures = 0; + + void Check(bool ok, std::string_view what) { + std::println("{} {}", ok ? "PASS" : "FAIL", what); + if (!ok) ++failures; + } + + bool Has(const std::string& text, std::string_view part) { + return text.find(part) != std::string::npos; + } + + struct AddArgs { + Required a; + std::int32_t b = 0; + }; + + struct Ran { + std::int32_t code = 0; + std::string out; + std::string err; + }; + + // crafter-debug, with its real transport, against the discovery + // directory the test set. + Ran Run(std::vector args) { + std::ostringstream out; + std::ostringstream err; + Cli::Environment environment; + environment.connect = Cli::ConnectQuic; + environment.out = &out; + environment.err = &err; + environment.haveLldb = [] { return false; }; + const std::int32_t code = Cli::Run(args, environment); + return { code, out.str(), err.str() }; + } + + // A request sent by hand, on its own stream: what came back, and how + // the stream ended. + struct Received { + std::mutex mutex; + std::condition_variable changed; + std::string bytes; + bool ended = false; + bool clean = false; + }; + + struct Raw { + std::shared_ptr received = std::make_shared(); + Crafter::QUICStream stream; + + bool Wait(std::chrono::milliseconds limit) { + std::unique_lock lock(received->mutex); + return received->changed.wait_for(lock, limit, [&] { return received->ended; }); + } + + std::string Bytes() { + std::lock_guard lock(received->mutex); + return received->bytes; + } + }; + + std::unique_ptr Send(Crafter::ClientQUIC& client, std::string request) { + auto raw = std::make_unique(); + raw->stream = client.OpenStream(); + const std::shared_ptr received = raw->received; + raw->stream.OnRecieve( + [received](std::span data) { + { + std::lock_guard lock(received->mutex); + received->bytes.append(data.data(), data.size()); + } + received->changed.notify_all(); + }, + [received](bool clean) { + { + std::lock_guard lock(received->mutex); + received->ended = true; + received->clean = clean; + } + received->changed.notify_all(); + }); + raw->stream.SendAsync(request.data(), static_cast(request.size()), true, {}); + return raw; + } + + std::string Request(std::string_view token, std::string_view method, std::string_view params = "{}") { + return std::format(R"({{"token":"{}","method":"{}","params":{},"timeoutMs":5000}})", token, method, params) + "\n"; + } + + Crafter::QUICClientCredentials Insecure() { + Crafter::QUICClientCredentials credentials; + credentials.insecureNoServerValidation = true; + return credentials; + } +} + +int main() { + std::thread([] { + std::this_thread::sleep_for(50s); + std::println("FAIL the test hung"); + std::fflush(stdout); + std::_Exit(1); + }).detach(); + + const fs::path dir = fs::temp_directory_path() / "crafter-debug-ShouldServeOverQuic"; + fs::remove_all(dir); + fs::create_directories(dir); + ::setenv("CRAFTER_DEBUG_DIR", dir.c_str(), 1); + + Crafter::ThreadPool::Start(4); + + Command("Math_Add", "Add two numbers.", [](const AddArgs& args) { return *args.a + args.b; }); + float speed = 1.5f; + Watch("speed", speed); + + auto session = std::make_unique(SessionOptions{ .name = "quic-test" }); + + std::atomic pumping = true; + std::thread app([&] { + std::uint32_t frame = 0; + while (pumping) { + Pump(); + if (++frame % 10 == 0) Log("tick", "tick {}", frame); + std::this_thread::sleep_for(2ms); + } + }); + + // ---- discovery ---- + const std::vector live = ListInstances(); + Check(live.size() == 1, "discovery lists the listening session"); + if (live.size() != 1) { + pumping = false; + app.join(); + std::fflush(stdout); + std::_Exit(1); + } + const InstanceRecord record = live.front().record; + Check(record.name == "quic-test" && record.port != 0 && record.token == session->Token(), "the record names it, its port and its token"); + Check(record.protocol == protocolVersion && record.sourceId == Detail::SourceId() && record.client == Detail::ClientPath(), + "the record carries the protocol, source id and client path"); + + // ---- the client, over QUIC ---- + Ran r = Run({ "status" }); + Check(r.code == 0 && Has(r.out, "quic-test frame"), std::format("status ({})", r.out + r.err)); + r = Run({ "call", "Math_Add", "a=2", "b=3" }); + Check(r.code == 0 && Has(r.out, "5"), std::format("call ({})", r.out + r.err)); + r = Run({ "call", "Math_Ad", "a=2" }); + Check(r.code == 6 && Has(r.err, "Math_Add"), std::format("an unknown command suggests the right one ({})", r.err)); + r = Run({ "get", "speed" }); + Check(r.code == 0 && Has(r.out, "1.5"), std::format("get ({})", r.out + r.err)); + r = Run({ "set", "speed", "4" }); + Check(r.code == 0, std::format("set ({})", r.out + r.err)); + r = Run({ "get", "speed" }); + Check(r.code == 0 && Has(r.out, "4"), std::format("get after set ({})", r.out + r.err)); + + const auto followStart = std::chrono::steady_clock::now(); + r = Run({ "log", "--follow", "--for", "1s", "--cat", "tick" }); + const auto followTook = std::chrono::steady_clock::now() - followStart; + Check(r.code == 0 && Has(r.out, "tick") && Has(r.err, "(follow ended)"), std::format("log --follow streams, then ends ({})", r.err)); + Check(followTook >= 900ms && followTook < 5s, std::format("the follow lasted its --for ({} ms)", std::chrono::duration_cast(followTook).count())); + + // ---- in parallel, on separate connections ---- + { + std::vector callers; + std::vector results(6); + for (std::size_t i = 0; i < results.size(); ++i) { + callers.emplace_back([&results, i] { + const std::string a = std::format("a={}", 100 * (i + 1)); + results[i] = Run({ "call", "Math_Add", a, "b=1" }); + }); + } + for (std::thread& caller : callers) caller.join(); + bool all = true; + for (std::size_t i = 0; i < results.size(); ++i) { + all = all && results[i].code == 0 && Has(results[i].out, std::format("{}", 100 * (i + 1) + 1)); + } + Check(all, "six clients at once each get their own answer"); + } + + // ---- by hand: what the client never sends ---- + auto client = std::make_unique(std::string("127.0.0.1"), record.port, std::string(alpn), Insecure()); + { + std::unique_ptr wrong = Send(*client, Request("not-the-token", "debug.status")); + Check(wrong->Wait(5s) && Has(wrong->Bytes(), "Unauthorized"), std::format("a wrong token is refused ({})", wrong->Bytes())); + + std::unique_ptr garbage = Send(*client, "this is not json\n"); + Check(garbage->Wait(5s) && Has(garbage->Bytes(), "InvalidRequest"), std::format("a request that isn't JSON is answered ({})", garbage->Bytes())); + + std::unique_ptr huge = Send(*client, std::string(std::size_t{ 2 } << 20, ' ')); + Check(huge->Wait(10s) && Has(huge->Bytes(), "InvalidRequest") && Has(huge->Bytes(), "at most"), + std::format("a request over 1 MiB is answered with an error ({})", huge->Bytes().substr(0, 200))); + + std::vector> parallel; + for (std::int32_t i = 0; i < 8; ++i) { + parallel.push_back(Send(*client, Request(record.token, "Math_Add", std::format(R"({{"a":{},"b":1000}})", i)))); + } + bool all = true; + for (std::int32_t i = 0; i < 8; ++i) { + all = all && parallel[i]->Wait(5s) && Has(parallel[i]->Bytes(), std::format(R"({{"result":{}}})", 1000 + i)); + } + Check(all, "eight streams of one connection at once each get their own answer"); + } + + // ---- the end, with a follower waiting ---- + std::unique_ptr follower = Send(*client, Request(record.token, "debug.log", R"({"follow":true,"forMs":60000,"last":0})")); + bool following = false; + for (std::int32_t attempt = 0; attempt < 100 && !following; ++attempt) { + std::unique_ptr status = Send(*client, Request(record.token, "debug.status")); + following = status->Wait(5s) && Has(status->Bytes(), R"("following":1)"); + if (!following) std::this_thread::sleep_for(20ms); + } + Check(following, "the follower is registered"); + pumping = false; + app.join(); + + const auto endStart = std::chrono::steady_clock::now(); + session.reset(); + const auto endTook = std::chrono::steady_clock::now() - endStart; + Check(endTook < 3s, std::format("the Session ends promptly with a client connected ({} ms)", std::chrono::duration_cast(endTook).count())); + Check(follower->Wait(5s) && Has(follower->Bytes(), "ShuttingDown") && !Has(follower->Bytes(), "Math_Add"), + std::format("the follower, with no backlog, is told the app is shutting down ({})", follower->Bytes().substr(0, 400))); + Check(ListInstances().empty(), "discovery forgets the session"); + follower.reset(); + client.reset(); + + const auto deadStart = std::chrono::steady_clock::now(); + const std::unique_ptr dead = Cli::ConnectQuic(record); + const auto reply = dead->Call(Value::Parse(Request(record.token, "debug.status")), 5s, {}); + const auto deadTook = std::chrono::steady_clock::now() - deadStart; + Check(!reply && reply.error().kind == Cli::CallError::Kind::Unreachable && deadTook < 5s, + std::format("a client finds the port dead in {} ms", std::chrono::duration_cast(deadTook).count())); + + std::fflush(stdout); + std::_Exit(failures == 0 ? 0 : 1); +} From 15e0b4954d0996a3a1f46f1f8528395890dd41ff Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Sun, 1 Nov 2026 03:07:09 +0100 Subject: [PATCH 03/10] docs: the QUIC transport, where Crafter.Network comes from, and limits The agent notes describe the wire, list :Transport and :Quic, explain how to build against a Network worktree with CRAFTER_DEBUG_NETWORK, and how to test a listening session. They add the one thread Crafter.Debug starts, the TLS and size limits, and gotchas: Crafter.Network must be named alike by a consumer that uses it too, last 0, and replying before raising a flag the main loop acts on. The README says the Session needs a pool worker, that a project pinning its own Crafter.Network can't add Crafter.Debug yet, and what ShouldServeOverQuic covers. Co-Authored-By: Claude Opus 5.5 --- AGENTS.template.md | 22 ++++++++++++++++++---- README.md | 8 ++++++-- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/AGENTS.template.md b/AGENTS.template.md index 97c0a8b..cc076dc 100644 --- a/AGENTS.template.md +++ b/AGENTS.template.md @@ -9,12 +9,14 @@ Crafter.Debug lets an agent inspect and drive an app while it runs. `project.cpp | Configuration | Holds | |---|---| | `Crafter.Debug.Core` (static) | What the app and the client share. Partitions:
• `:Value`: JSON value with its own parser and printer
• `:Reflect`: aggregate members and scoped-enum names, without ``
• `:Codec`: encode, decode, describe
• `:Result`: Error, Result, Async
• `:Protocol`: envelope, codes, versions, base64
• `:BuildInfo`: source id and client path, generated
• `:Discovery`: instance files and the lifetime lock | -| `Crafter.Debug.Cli` (static) | The client's logic: `Cli::Run`, with the transport injected. | +| `Crafter.Debug.Cli` (static) | The client's logic:
• `:Client`: `Cli::Run`, with the transport injected
• `:Quic`: `ConnectQuic`, the transport `main` injects | | `crafter.debug-cli` (executable) | `crafter-debug`: `main.cpp` plus the transport. | -| `Crafter.Debug` (static, returned) | What an app links. Partitions:
• `:Registry`: commands, watches, samples, Group
• `:Timeline`: Log, Check, Emit
• `:Condition`: what wait and pause --when evaluate
• `:Session`: serving requests, Pump, Advance | +| `Crafter.Debug` (static, returned) | What an app links. Partitions:
• `:Registry`: commands, watches, samples, Group
• `:Timeline`: Log, Check, Emit
• `:Condition`: what wait and pause --when evaluate
• `:Session`: serving requests, Pump, Advance
• `:Transport`: the QUIC listener, exporting nothing | The client is one of the library's dependencies only so that a consumer's debug build compiles it in the same pass. +**The wire:** QUIC on 127.0.0.1, ALPN `crafter-debug/1`, through Crafter.Network. One request per stream the client opens: the request object and a FIN; back come zero or more stream lines (`{"entry": ...}`), then the reply (`{"result"}` or `{"error"}`) and a FIN. Every object is one line of JSON. `implementations/Crafter.Debug-Transport.cpp` opens with the rules on who may hold which lock. + `tooling/` is a separate project, `Crafter.Debug.Build`. Consumers load it with `#pragma crafter dependency ... project "tooling/project.cpp"`. Its `Debug::Use` adds the library under the consumer's flag and writes the consumer's `build/crafter-debug` launcher at load time. `tooling/AGENTS.consumer.md` is the only notes file consumers get, and it covers both driving the app and coding with it. Crafter.Build merges a dependency's notes only when that dependency is in the build: @@ -34,12 +36,17 @@ Tests run inside the Flatpak sandbox, where: Every `flatpak run` is its own sandbox with its own pid namespace. +**Crafter.Network** comes from forgejo, unpinned, so every load `git pull`s it. `--local` takes `../Crafter.Network`, and `CRAFTER_DEBUG_NETWORK=` takes any checkout, a worktree say. The environment reaches the build inside the Flatpak. + +The transport needs Network's callback API (`OnRecieve`, `SendAsync`, `OnClosed`, `ListenerQUIC` with a bind address and `Port()`) and its fix that lets stopped streams finish before `~ClientQUIC` closes. Until those reach Network's `master`, build with `CRAFTER_DEBUG_NETWORK` pointing at a checkout of the branch that has them. + ## Build and test commands ```bash flatpak run --die-with-parent net.catcrafts.CrafterBuild # the library, core, client flatpak run --die-with-parent net.catcrafts.CrafterBuild test # every test flatpak run --die-with-parent net.catcrafts.CrafterBuild test 'Should*Client*' +CRAFTER_DEBUG_NETWORK=../Crafter.Network-quic-callbacks/project.cpp flatpak run --die-with-parent net.catcrafts.CrafterBuild test # Network from a worktree cd examples/Basic && flatpak run --die-with-parent net.catcrafts.CrafterBuild --debug-api # a consumer's debug build ``` @@ -50,7 +57,7 @@ A test's own output appears only when it fails. To see its PASS lines, run the b **Registration:** tests are declared in `project.cpp`, and only when this is the top-level project: - `coreTests` link the core; - `libraryTests` link the library; -- `ShouldRunClientCommands` links the library and the client. +- `ShouldRunClientCommands` and `ShouldServeOverQuic` link the library and the client. The second listens for real and drives the session over loopback with the client's own transport. **Shape of a test:** - Name it `Should`. @@ -59,6 +66,8 @@ A test's own output appears only when it fails. To see its PASS lines, run the b **Session tests** use `Session({ .listen = false })` and hand requests to `Session::Handle` through a fake `Detail::Exchange`. Pump from the test's own thread, or from one thread playing the app. +**A listening session** needs `ThreadPool::Start()` with a worker first, `CRAFTER_DEBUG_DIR` set, and `std::_Exit` at the end after `std::fflush(stdout)`: msquic's teardown at exit waits for connections. Run it 30 times in a row before trusting it; one transport race showed in 4 runs of 30. + **Files and discovery:** - Temporary files go under `fs::temp_directory_path() / "crafter-debug-"`. - Discovery tests set `CRAFTER_DEBUG_DIR` there. @@ -87,7 +96,7 @@ A test's own output appears only when it fails. To see its PASS lines, run the b ## Design rules 1. **Release builds never link Crafter.Debug.** Consumers gate it through `Debug::Use` and their own define. The library has no no-op mode. -2. **No thread of Crafter.Debug's own, and nothing ever blocks one.** Transport threads only queue work or answer from the session's own state; the Pump thread runs app code; `Detail::Exchange::Reply` and `Stream` never block. The thread pool belongs to the host app. +2. **No thread of Crafter.Debug's own, and nothing ever blocks one.** Transport threads only queue work or answer from the session's own state; the Pump thread runs app code; `Detail::Exchange::Reply` and `Stream` never block. The thread pool belongs to the host app. Network's own threads are ListenerQUIC's accept loop and msquic's workers. The one thread Crafter.Debug starts is the timer that ends the process 200 ms after `quit --force` has sent its reply. 3. **App code runs only on the Pump thread:** handlers, getters, setters, and condition evaluation. Never hold the session mutex while calling it, since app code logs, and logging re-enters the session. 4. **Nothing the Flatpak compiles runs on the host.** The launcher is POSIX sh and starts the client with `flatpak run`. On older hosts, the bundled libc++ and the runtime's glibc do not load. 5. **The client is built with the app.** `debug.hello`, the ALPN `crafter-debug/1` and the discovery record's `protocol` and `sourceId` are frozen. Bump `protocolVersion` on any wire change. @@ -105,6 +114,8 @@ A test's own output appears only when it fails. To see its PASS lines, run the b ## Security - **Debug builds only.** The session listens on 127.0.0.1, and every request must carry the instance's 128-bit token. +- **TLS** uses Crafter.Network's per-user self-signed certificate, which the client does not check. The token is what proves who asks. +- **Size limits:** a request is at most 1 MiB, answered with an error past that. A `log --follow` client more than 8 MiB behind is reset. - **Discovery files** are 0600 in a 0700 directory. - **Debuggers:** the session calls `prctl(PR_SET_PTRACER, PR_SET_PTRACER_ANY)`, so a debugger the agent starts can attach. That is another reason it never ships in a release build. @@ -117,3 +128,6 @@ A test's own output appears only when it fails. To see its PASS lines, run the b - **A consumer's `#ifdef`'d `import Crafter.Debug;`** is harmless in its plain builds. - **`project.cpp` regenerates `build/generated//Crafter.Debug.Core-BuildInfo.cpp`** when the sources or the client's path change. `crafter-build clean` removes it, and the next load writes it again. - **The launcher is written by `Debug::Use` while crafter-build loads the consumer,** not by the build. A plain build keeps an existing launcher; a fresh plain build writes the stub. +- **A consumer that depends on Crafter.Network itself** must name it exactly as `project.cpp` here does (same URL, no pin, same args). Otherwise the graph holds two Crafter.Network configurations, and crafter-build refuses with "module … built twice". +- **`debug.log`'s `last` of 0 means no backlog**, as `tail -n 0` does. A `TimelineQuery`'s `last` of 0 means all. +- **Answer before you raise a flag the main loop acts on.** The loop may end the Session at once. A reply given before the connections close is delivered; one given after is not. `quit` replies first, then sets `QuitRequested`. diff --git a/README.md b/README.md index ade6e0d..604cc32 100644 --- a/README.md +++ b/README.md @@ -68,11 +68,13 @@ extern "C" Configuration CrafterBuildProject(std::span a A dependency that two paths in the build graph share must get the **same arg list on both**. Otherwise crafter-build refuses the graph with "module … built twice". Build one `depArgs` and give it to every dependency, as above. +That includes Crafter.Network, which Crafter.Debug takes from `https://forgejo.catcrafts.net/Catcrafts/Crafter.Network.git`, unpinned (with `--local`, from `../Crafter.Network`). If your project depends on Crafter.Network too, name it the same way. A project that pins its own Crafter.Network to a commit can't add Crafter.Debug yet. + **2. In `main`**, start a session and pump it once per frame. Gate the parts the agent should be able to pause: ```cpp import MyApp.DebugShim; // see "Without #ifdefs" below -ThreadPool::Start(); // the session's transport runs on the pool +ThreadPool::Start(); // the session's transport needs a pool worker #ifdef MYAPP_DEBUG_API Debug::Session debug({ .name = "MyApp" }); MyApp::RegisterDebug(); // your commands and watches @@ -84,7 +86,7 @@ while (running && !Debug::QuitRequested()) { } ``` -The `Session` must be destroyed before `main` returns and before `ThreadPool::Stop()`, because msquic waits for open connections at exit. A stack object in `main`, as above, does that. +The `Session` must be destroyed before `main` returns and before `ThreadPool::Stop()`, because msquic waits for open connections at exit. A stack object in `main`, as above, does that. It needs the pool started with at least one worker (`Start(1)` starts none) and throws without one. **3. Register what the agent needs** in debug-only source files, which `project.cpp` adds only under the flag. See "The app side" below. @@ -319,6 +321,8 @@ cd examples/Basic && crafter-build --debug-api -r - **Library and core tests** run in-process, against a `Session` built with `listen = false` and a fake transport. - **`ShouldRunClientCommands`** runs the real client against a real session that way. +- **`ShouldServeOverQuic`** runs a listening session and drives it over loopback with the client's own QUIC transport. It covers discovery, calls, `log --follow`, wrong tokens, oversized requests, parallel requests, and shutdown with a client connected. +- **Crafter.Network** comes from forgejo, or from `../Crafter.Network` with `--local`. To build against another checkout, a worktree say, set `CRAFTER_DEBUG_NETWORK` to its `project.cpp`. - **`AGENTS.template.md`** has the conventions. Licensed LGPL-3.0-only; the examples are MIT. From 6987033d6b0fec1a6625d0ef1865b3c0a0cc60f9 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Sun, 1 Nov 2026 03:21:41 +0100 Subject: [PATCH 04/10] fix(core): encode a float as its shortest decimal A float was widened to double before encoding, so every float watch, argument default and result read as the double nearest it: gravity -9.81 showed as -9.8100004196167, a radius of 0.1 as 0.10000000149011612, in `get`, `help` and call results alike. Agents read these, and copy them back into `set` and `call`. Codec now encodes the shortest decimal that reads back as the same float (std::to_chars), as a double. Decoding is unchanged, and so is every value: 0.1 decodes to the float 0.1f came from. A double is encoded as before. Co-Authored-By: Claude Opus 5.5 --- interfaces/Crafter.Debug.Core-Codec.cppm | 15 ++++++++++++++- tests/ShouldResolveWatchPaths/main.cpp | 2 +- tests/ShouldRoundTripAggregates/main.cpp | 11 ++++++++++- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/interfaces/Crafter.Debug.Core-Codec.cppm b/interfaces/Crafter.Debug.Core-Codec.cppm index 5c6c23c..9e38323 100644 --- a/interfaces/Crafter.Debug.Core-Codec.cppm +++ b/interfaces/Crafter.Debug.Core-Codec.cppm @@ -192,7 +192,20 @@ export namespace Crafter::Debug { template struct Codec { - static Value Encode(T value) { return Value(static_cast(value)); } + static Value Encode(T value) { + // A float as the shortest decimal that reads back as it: 0.1f + // widened is 0.10000000149011612, which is what an agent would + // otherwise read for every float. + if constexpr (sizeof(T) < sizeof(double)) { + if (std::isfinite(value)) { + std::array text{}; + const auto written = std::to_chars(text.data(), text.data() + text.size(), value); + double shortest = 0; + if (written.ec == std::errc() && std::from_chars(text.data(), written.ptr, shortest).ec == std::errc()) return Value(shortest); + } + } + return Value(static_cast(value)); + } static void Decode(const Value& value, T& out, DecodeContext& context) { if (value.IsNumber()) { diff --git a/tests/ShouldResolveWatchPaths/main.cpp b/tests/ShouldResolveWatchPaths/main.cpp index 23a95fd..3a48747 100644 --- a/tests/ShouldResolveWatchPaths/main.cpp +++ b/tests/ShouldResolveWatchPaths/main.cpp @@ -51,7 +51,7 @@ int main() { }); Watch("score", [] { return 1234; }); - Check(registry().Resolve("physics.gravity").value.AsNumber() == static_cast(-9.81F), "a variable watch reads the variable"); + Check(registry().Resolve("physics.gravity").value.AsNumber() == -9.81, "a variable watch reads the variable, a float as its shortest decimal"); Check(registry().Resolve("bodies[1].pos.y").value.AsNumber() == 4, "a path reads into a watched value"); Check(registry().Resolve("bodies[1]").value.Find("id")->AsInteger() == 2, "an index reads an item"); diff --git a/tests/ShouldRoundTripAggregates/main.cpp b/tests/ShouldRoundTripAggregates/main.cpp index be30d8e..f6e0abe 100644 --- a/tests/ShouldRoundTripAggregates/main.cpp +++ b/tests/ShouldRoundTripAggregates/main.cpp @@ -6,7 +6,9 @@ // maps, a scoped enum, Required) through Value and its text, with no // hand-written serialization. Fields the input leaves out keep their default // member initializers, null clears an optional, and an enum also accepts its -// integer. Guards the reflection that command arguments and results rely on. +// integer. A float encodes as its shortest decimal, 0.1 rather than the +// 0.10000000149011612 it widens to, which every float watch used to show. +// Guards the reflection that command arguments and results rely on. import std; import Crafter.Debug.Core; using namespace Crafter::Debug; @@ -74,6 +76,13 @@ int main() { Check(!partial.target.has_value(), "null clears an optional"); Check(partial.mode == Game::Mode::Build, "an enum accepts its integer"); + Check(Encode(0.1F).Dump() == "0.1" && Encode(-9.81F).Dump() == "-9.81" && Encode(4.0F).Dump() == "4.0", + std::format("a float reads as its shortest decimal ({}, {})", Encode(0.1F).Dump(), Encode(-9.81F).Dump())); + float tenth = 0; + DecodeContext tenthContext; + Decode(Value::Parse(Encode(0.1F).Dump()), tenth, tenthContext); + Check(tenthContext.Ok() && tenth == 0.1F && Encode(0.1).Dump() == "0.1", "and decodes to the same float; a double is unchanged"); + float nan = 0; DecodeContext nanContext; Decode(Encode(std::numeric_limits::quiet_NaN()), nan, nanContext); From 7574ad5c4e788cca523046eb4d62e292a341a21d Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Sun, 1 Nov 2026 03:21:41 +0100 Subject: [PATCH 05/10] fix(cli): take global flags after the command, and call --json again Only --json was taken after the command, so `wait --paused --timeout 60s`, the order the guide's own synopsis and agents use, failed with "unknown argument '--timeout'", and `-i` after the command did too. And since that --json was taken as the output flag, `call X --json '{...}'`, the documented way to pass arguments as JSON, never reached call: its object then failed as "not key=value". --timeout, -i and --json now go anywhere on the line; no command has options of those names. After `call`, a --json followed by an object is the arguments, and any other --json the output flag. A trailing --timeout or -i without its value is a usage error that says so. Co-Authored-By: Claude Opus 5.5 --- implementations/Crafter.Debug.Cli-Client.cpp | 17 +++++++++++++---- tests/ShouldRunClientCommands/main.cpp | 10 ++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/implementations/Crafter.Debug.Cli-Client.cpp b/implementations/Crafter.Debug.Cli-Client.cpp index 6e03563..677af1c 100644 --- a/implementations/Crafter.Debug.Cli-Client.cpp +++ b/implementations/Crafter.Debug.Cli-Client.cpp @@ -878,13 +878,19 @@ namespace { for (std::size_t i = 0; i < args.size(); ++i) { const std::string_view arg = args[i]; if (!options.command.empty()) { - // Global flags may follow the command too, before its own. - if (arg == "--json") { + // Global flags go anywhere, as agents write them: `wait + // --paused --timeout 60s`. No command has options of these + // names, but for `call --json '{...}'`, its arguments as JSON. + const bool callArguments = options.command == "call" && arg == "--json" && i + 1 < args.size() && args[i + 1].starts_with('{'); + if (arg == "--json" && !callArguments) { options.json = true; continue; } - options.rest.emplace_back(arg); - continue; + if (arg != "--timeout" && arg != "-i" && arg != "--instance") { + options.rest.emplace_back(arg); + if (callArguments) options.rest.emplace_back(args[++i]); + continue; + } } if ((arg == "-i" || arg == "--instance") && i + 1 < args.size()) { options.instance = args[++i]; @@ -897,6 +903,9 @@ namespace { return exitUsage; } options.timeout = *timeout; + } else if (arg == "--timeout" || arg == "-i" || arg == "--instance") { + *env.err << std::format("crafter-debug: {} needs a value\n", arg); + return exitUsage; } else if (arg == "-h" || arg == "--help") { *env.out << usage; return exitOk; diff --git a/tests/ShouldRunClientCommands/main.cpp b/tests/ShouldRunClientCommands/main.cpp index 4694e7c..28451e3 100644 --- a/tests/ShouldRunClientCommands/main.cpp +++ b/tests/ShouldRunClientCommands/main.cpp @@ -161,6 +161,10 @@ int main() { Check(r.code == 0 && Has(r.out, "'score >= 1' held at frame"), "wait for a condition"); r = Run({ "--timeout", "100ms", "wait", "score < 0" }, instances); Check(r.code == 5 && Has(r.err, "was not true within"), "a wait that times out exits 5 and says what the paths were"); + r = Run({ "wait", "score < 0", "--timeout", "100ms" }, instances); + Check(r.code == 5 && Has(r.err, "was not true within"), std::format("global flags also go after the command's own ({})", r.err)); + r = Run({ "wait", "--frames", "1", "--timeout" }, instances); + Check(r.code == 2 && Has(r.err, "--timeout needs a value"), "a global flag without its value is a usage error"); r = Run({ "log", "--last", "5" }, instances); Check(r.code == 0 && Has(r.out, "AGENT"), "log shows the agent's actions"); @@ -178,6 +182,12 @@ int main() { } catch (const ParseError&) { } Check(r.code == 0 && json, "--json prints JSON"); + r = Run({ "status", "--json" }, instances); + Check(r.code == 0 && r.out.starts_with("{"), "--json after the command too"); + r = Run({ "call", "Player_SetAim", "--json", R"({"slot": 4, "yaw": 5})" }, instances); + Check(r.code == 0 && Has(r.out, R"({"slot":4,"yaw":5.0})"), std::format("call takes its arguments as JSON ({})", r.out + r.err)); + r = Run({ "call", "Player_SetAim", "--json", R"({"slot": 4})", "yaw=7", "--json" }, instances); + Check(r.code == 0 && Has(r.out, R"({"slot":4,"yaw":7.0})"), std::format("JSON arguments with key=value on top, and --json after them ({})", r.out + r.err)); r = Run({ "stauts" }, instances); Check(r.code == 2 && Has(r.err, "did you mean 'status'"), "a mistyped command name gets a suggestion"); From 2c77cdcfab2bccbb896cac14303e4daa93176e95 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Sun, 1 Nov 2026 03:21:41 +0100 Subject: [PATCH 06/10] fix(session): report the frame a pause began at Frames go on counting while paused, since Pump still runs, but status gave no pause frame and `wait --paused` replied with the current one. A wait that came minutes after `pause --on-check` caught a failure at frame 237 said "paused at frame 5475", which an agent takes for when the check failed. The session records the frame in PauseNow, and status and the paused wait report it as pausedAt. The client prints "PAUSED since frame N", and "paused at frame N (now M)" when the two differ. Co-Authored-By: Claude Opus 5.5 --- implementations/Crafter.Debug-Session.cpp | 13 ++++++++++--- implementations/Crafter.Debug.Cli-Client.cpp | 11 +++++++++-- tests/ShouldPauseAndStep/main.cpp | 10 ++++++++++ 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/implementations/Crafter.Debug-Session.cpp b/implementations/Crafter.Debug-Session.cpp index d4df4b3..932f163 100644 --- a/implementations/Crafter.Debug-Session.cpp +++ b/implementations/Crafter.Debug-Session.cpp @@ -269,6 +269,9 @@ namespace Crafter::Debug { bool armedOnCheck = false; bool armedTrap = false; std::string pauseReason; + // The frame the pause began at. Frames go on counting while paused, + // so the current one says nothing about when it stopped. + std::uint64_t pausedAt = 0; bool shutDown = false; // The Pump thread's own. @@ -333,11 +336,12 @@ namespace Crafter::Debug { } void PauseNow(std::string reason, bool trap) { - paused.store(true); { std::lock_guard lock(mutex); pauseReason = reason; + pausedAt = frame.load(); } + paused.store(true); Detail::Timeline::Instance().Append(Detail::MakeEntry(EntryKind::Pause, "debug", std::format("paused: {}", reason), std::source_location::current())); if (trap && TracerAttached()) ::raise(SIGTRAP); } @@ -429,6 +433,7 @@ namespace Crafter::Debug { { std::lock_guard lock(mutex); status.Set("pauseReason", Value(pauseReason)); + if (paused.load()) status.Set("pausedAt", Value(pausedAt)); status.Set("queued", Value(jobs.size())); status.Set("waiting", Value(waiters.size())); status.Set("following", Value(followers.size())); @@ -615,7 +620,7 @@ namespace Crafter::Debug { waiter.kind = WaitKind::Paused; if (paused.load()) { std::lock_guard lock(mutex); - return exchange->Reply(MakeReply(Object({ { "frame", Value(frame.load()) }, { "reason", Value(pauseReason) } }))); + return exchange->Reply(MakeReply(Object({ { "frame", Value(frame.load()) }, { "pausedAt", Value(pausedAt) }, { "reason", Value(pauseReason) } }))); } } std::lock_guard lock(mutex); @@ -914,11 +919,13 @@ namespace Crafter::Debug { case WaitKind::Paused: if (paused.load()) { std::string reason; + std::uint64_t since = 0; { std::lock_guard lock(mutex); reason = pauseReason; + since = pausedAt; } - waiter.exchange->Reply(MakeReply(Object({ { "frame", Value(current) }, { "reason", Value(reason) } }))); + waiter.exchange->Reply(MakeReply(Object({ { "frame", Value(current) }, { "pausedAt", Value(since) }, { "reason", Value(reason) } }))); done = true; } break; diff --git a/implementations/Crafter.Debug.Cli-Client.cpp b/implementations/Crafter.Debug.Cli-Client.cpp index 677af1c..f52a180 100644 --- a/implementations/Crafter.Debug.Cli-Client.cpp +++ b/implementations/Crafter.Debug.Cli-Client.cpp @@ -474,8 +474,11 @@ namespace { const Value& s = *result; const bool paused = s.Find("paused")->AsBool(); const std::string reason = s.Find("pauseReason") ? s.Find("pauseReason")->AsString() : std::string(); + const Value* pausedAt = s.Find("pausedAt"); Out() << std::format("{} frame {} {:.1f} fps {}\n", s.Find("name")->AsString(), s.Find("frame")->Dump(), s.Find("fps")->AsNumber(), - paused ? std::format("PAUSED ({})", reason) : std::string("running")); + !paused ? std::string("running") + : pausedAt != nullptr ? std::format("PAUSED since frame {} ({})", pausedAt->Dump(), reason) + : std::format("PAUSED ({})", reason)); std::string armed; if (const Value* when = s.Find("armed")->Find("when")) armed += std::format(" pause armed: when '{}'", when->AsString()); if (s.Find("armed")->Find("onCheck") != nullptr) armed += " pause armed: on the next failing check"; @@ -703,7 +706,11 @@ namespace { } else if (const Value* event = r.Find("event")) { Out() << std::format("{} at frame {}: {}\n", event->AsString(), r.Find("frame")->Dump(), Display(*r.Find("payload"))); } else if (const Value* reason = r.Find("reason")) { - Out() << std::format("paused at frame {}: {}\n", r.Find("frame")->Dump(), reason->AsString()); + // When it stopped, not when this wait looked: frames go on + // counting while paused. + const Value* pausedAt = r.Find("pausedAt"); + const std::string since = pausedAt != nullptr ? pausedAt->Dump() : r.Find("frame")->Dump(); + Out() << std::format("paused at frame {}{}: {}\n", since, since != r.Find("frame")->Dump() ? std::format(" (now {})", r.Find("frame")->Dump()) : std::string(), reason->AsString()); } else { Out() << std::format("frame {}\n", r.Find("frame")->Dump()); } diff --git a/tests/ShouldPauseAndStep/main.cpp b/tests/ShouldPauseAndStep/main.cpp index f17aafd..0a2b973 100644 --- a/tests/ShouldPauseAndStep/main.cpp +++ b/tests/ShouldPauseAndStep/main.cpp @@ -92,7 +92,17 @@ int main() { const Value pausedReply = ResultOf(*waitPaused); const Value* reason = pausedReply.Find("reason"); Check(reason != nullptr && reason->AsString().find("counter >= 3") != std::string::npos, "wait --paused replies with the reason"); + // Frames go on while paused: a wait that comes later must still say + // when the pause began, not the frame it looked at. + const std::uint64_t pausedAt = pausedReply.Find("pausedAt")->AsInteger(); + for (std::int32_t frame = 0; frame < 5; ++frame) Pump(); + const Value later = ResultOf(*Send("debug.wait", R"({"paused": true})")); + const Value statusWhilePaused = ResultOf(*Send("debug.status")); + Check(later.Find("pausedAt")->AsInteger() == static_cast(pausedAt) && later.Find("frame")->AsInteger() >= static_cast(pausedAt) + 5 + && statusWhilePaused.Find("pausedAt")->AsInteger() == static_cast(pausedAt), + std::format("a later wait and status say when the pause began (frame {}), not the frame now", pausedAt)); Send("debug.resume"); + Check(ResultOf(*Send("debug.status")).Find("pausedAt") == nullptr, "status has no pausedAt once resumed"); Check(ResultOf(*Send("debug.pause", R"({"onCheck": true})")).Find("armed")->AsString() == "onCheck", "pause --on-check arms"); Check(Advance(), "running before the check fails"); From 5d34df7afb6a55f03ecd3e6b031e7713ba607f28 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Sun, 1 Nov 2026 03:21:41 +0100 Subject: [PATCH 07/10] fix(cli): print lldb by its path, and report a held app as held Tried on the example with the app's sandbox, two things were wrong: - `debugger` printed `flatpak enter lldb -p `, and flatpak enter runs a command without the sandbox's PATH, which /app/bin is on: "Failed to execute child process lldb". It now prints the path it found, /app/bin/lldb, which attached, listed threads and detached. - With the app held by that debugger, the handshake times out, and the client said "cannot reach basic on port ..." and exited 3, no instance, though its lock said it lived. It now says the app is alive but not answering, stopped in a debugger or hung, prints how to attach, check and end it, and exits 5. Environment::haveLldb becomes lldb, the path or empty. ShouldServeOverQuic covers the second with an instance whose lock is held and whose port is silent. Co-Authored-By: Claude Opus 5.5 --- implementations/Crafter.Debug.Cli-Client.cpp | 14 ++++++++++---- implementations/main.cpp | 7 ++++++- interfaces/Crafter.Debug.Cli-Client.cppm | 6 ++++-- tests/ShouldRunClientCommands/main.cpp | 2 +- tests/ShouldServeOverQuic/main.cpp | 15 +++++++++++++-- 5 files changed, 34 insertions(+), 10 deletions(-) diff --git a/implementations/Crafter.Debug.Cli-Client.cpp b/implementations/Crafter.Debug.Cli-Client.cpp index f52a180..3cbb467 100644 --- a/implementations/Crafter.Debug.Cli-Client.cpp +++ b/implementations/Crafter.Debug.Cli-Client.cpp @@ -360,10 +360,15 @@ namespace { const bool alive = std::ranges::any_of(env.list(), [&](const Instance& candidate) { return candidate.record.instanceId == instance->record.instanceId; }); if (!alive) { Err() << std::format("crafter-debug: {} has exited\n", instance->record.name); - } else { - Err() << std::format("crafter-debug: cannot reach {} on port {}: {}\n", instance->record.name, instance->record.port, why); + return exitNoInstance; } - return exitNoInstance; + // Its lock says it lives, yet not even the handshake is + // answered: the whole process is stopped, by a debugger most + // likely, or its network threads are stuck. + Err() << std::format("crafter-debug: {} is alive but not answering on port {} ({}): stopped in a debugger, or hung.\n", + instance->record.name, instance->record.port, why); + PrintAttach(true); + return exitTimeout; } // A request that got no reply. The app may be gone, stopped in a @@ -398,7 +403,8 @@ namespace { void PrintAttach(bool toErr) { std::ostream& stream = toErr ? Err() : Out(); const InstanceRecord& record = instance->record; - const std::string debugger = env.haveLldb && env.haveLldb() ? "lldb" : "gdb"; + const std::string lldb = env.lldb ? env.lldb() : std::string(); + const std::string debugger = lldb.empty() ? std::string("gdb") : lldb; if (!record.flatpakInstance.empty()) { stream << std::format(" attach (on the host): flatpak enter {} {} -p {}\n", record.flatpakInstance, debugger, record.pid); stream << std::format(" check whether a debugger holds it: flatpak enter {} grep State /proc/{}/status (t = stopped by a tracer)\n", record.flatpakInstance, record.pid); diff --git a/implementations/main.cpp b/implementations/main.cpp index fb59d17..d0e4f57 100644 --- a/implementations/main.cpp +++ b/implementations/main.cpp @@ -17,7 +17,12 @@ int main(int argc, char** argv) { { Cli::Environment environment; environment.connect = Cli::ConnectQuic; - environment.haveLldb = [] { return std::filesystem::exists("/app/bin/lldb") || std::filesystem::exists("/usr/bin/lldb"); }; + environment.lldb = [] { + for (const char* path : { "/app/bin/lldb", "/usr/bin/lldb" }) { + if (std::filesystem::exists(path)) return std::string(path); + } + return std::string(); + }; // Run closes its connections before it returns. code = Cli::Run(args, environment); } diff --git a/interfaces/Crafter.Debug.Cli-Client.cppm b/interfaces/Crafter.Debug.Cli-Client.cppm index 5d8dd09..02d56d4 100644 --- a/interfaces/Crafter.Debug.Cli-Client.cppm +++ b/interfaces/Crafter.Debug.Cli-Client.cppm @@ -58,8 +58,10 @@ export namespace Crafter::Debug::Cli { std::ostream* err = &std::cerr; // Where a screenshot without -o goes, relative paths included. std::filesystem::path cwd = std::filesystem::current_path(); - // Whether `lldb` exists here, for the attach command `debugger` prints. - std::function haveLldb; + // Where lldb is here, for the attach command `debugger` prints: its + // absolute path, since `flatpak enter` runs a command without the + // sandbox's PATH, which /app/bin is on. Empty for none: gdb then. + std::function lldb; }; std::int32_t Run(std::span args, Environment& environment); diff --git a/tests/ShouldRunClientCommands/main.cpp b/tests/ShouldRunClientCommands/main.cpp index 28451e3..2817e04 100644 --- a/tests/ShouldRunClientCommands/main.cpp +++ b/tests/ShouldRunClientCommands/main.cpp @@ -85,7 +85,7 @@ namespace { environment.out = &out; environment.err = &err; environment.cwd = fs::temp_directory_path() / "crafter-debug-ShouldRunClientCommands"; - environment.haveLldb = [] { return false; }; + environment.lldb = [] { return std::string(); }; const std::int32_t code = Cli::Run(args, environment); return { code, out.str(), err.str() }; } diff --git a/tests/ShouldServeOverQuic/main.cpp b/tests/ShouldServeOverQuic/main.cpp index e9d6096..9b39170 100644 --- a/tests/ShouldServeOverQuic/main.cpp +++ b/tests/ShouldServeOverQuic/main.cpp @@ -14,7 +14,9 @@ // connections, each get their own reply; // - destroying the Session answers a waiting follower and closes promptly // with a client still connected, and a client then finds the port dead -// within seconds. +// within seconds; +// - an instance alive by its lock but silent at its port, as one a +// debugger holds, is reported as such with how to attach, not as gone. #include #include import std; @@ -58,7 +60,7 @@ namespace { environment.connect = Cli::ConnectQuic; environment.out = &out; environment.err = &err; - environment.haveLldb = [] { return false; }; + environment.lldb = [] { return std::string(); }; const std::int32_t code = Cli::Run(args, environment); return { code, out.str(), err.str() }; } @@ -259,6 +261,15 @@ int main() { Check(!reply && reply.error().kind == Cli::CallError::Kind::Unreachable && deadTook < 5s, std::format("a client finds the port dead in {} ms", std::chrono::duration_cast(deadTook).count())); + // Alive by its lock, with nothing answering at its port: what a client + // sees of an app a debugger holds. + { + const Registration stopped(InstanceRecord{ .name = "held", .port = record.port, .token = "t", .protocol = protocolVersion, .sourceId = std::string(Detail::SourceId()) }); + const Ran held = Run({ "status" }); + Check(held.code == 5 && Has(held.err, "alive but not answering") && Has(held.err, "attach"), + std::format("an app alive but silent is reported as held, with how to attach (exit {}: {})", held.code, held.err)); + } + std::fflush(stdout); std::_Exit(failures == 0 ? 0 : 1); } From 6da3b274a6a3032758ad600698792ec3e289bd46 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Sun, 1 Nov 2026 03:21:41 +0100 Subject: [PATCH 08/10] feat(tooling): Debug::Use says whether it added the library A consumer adds its debug-only sources, the ones that import Crafter.Debug, under the same condition Use adds the library. Testing its own flag is not that condition: a build with the flag for a target other than native Linux gets neither the library nor the define, and its debug-only sources would then fail to import Crafter.Debug. Use now returns whether it added them, and the README shows the pattern. Co-Authored-By: Claude Opus 5.5 --- README.md | 12 +++++++----- tooling/interfaces/Crafter.Debug.Build.cppm | 19 ++++++++++++------- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 604cc32..817e5b5 100644 --- a/README.md +++ b/README.md @@ -56,15 +56,17 @@ extern "C" Configuration CrafterBuildProject(std::span a if (arg != "--debug-api") depArgs.emplace_back(arg); } // ...give depArgs to every other dependency too... - Debug::Use(cfg, { .enabled = opts.Has("--debug-api"), .flag = "--debug-api", - .define = "MYAPP_DEBUG_API", .args = depArgs }); - // ... + const bool debugApi = Debug::Use(cfg, { .enabled = opts.Has("--debug-api"), .flag = "--debug-api", + .define = "MYAPP_DEBUG_API", .args = depArgs }); + // ...your sources, plus the debug-only ones when debugApi... } ``` **What `Use` does with the flag:** -- **Flag on, native Linux target:** it adds the define and the library, from the checkout the pragma names, and writes `build/crafter-debug`. -- **Otherwise:** it adds nothing, and release builds never link Crafter.Debug. Until a debug build has run, `build/crafter-debug` is a stub that says to build one. +- **Flag on, native Linux target:** it adds the define and the library, from the checkout the pragma names, writes `build/crafter-debug`, and returns true. +- **Otherwise:** it adds nothing and returns false, and release builds never link Crafter.Debug. Until a debug build has run, `build/crafter-debug` is a stub that says to build one. + +Add your debug-only sources when `Use` returns true, not when the flag is on: a non-Linux target with the flag gets neither the library nor the define. A dependency that two paths in the build graph share must get the **same arg list on both**. Otherwise crafter-build refuses the graph with "module … built twice". Build one `depArgs` and give it to every dependency, as above. diff --git a/tooling/interfaces/Crafter.Debug.Build.cppm b/tooling/interfaces/Crafter.Debug.Build.cppm index d2c773b..0c2aee6 100644 --- a/tooling/interfaces/Crafter.Debug.Build.cppm +++ b/tooling/interfaces/Crafter.Debug.Build.cppm @@ -6,8 +6,9 @@ // #pragma crafter dependency "https://forgejo.catcrafts.net/Catcrafts/Crafter.Debug.git" project "tooling/project.cpp" // import Crafter.Debug.Build; // ... -// Debug::Use(cfg, { .enabled = opts.Has("--debug-api"), .flag = "--debug-api", -// .define = "MYAPP_DEBUG_API", .args = depArgs }); +// const bool debugApi = Debug::Use(cfg, { .enabled = opts.Has("--debug-api"), .flag = "--debug-api", +// .define = "MYAPP_DEBUG_API", .args = depArgs }); +// if (debugApi) { /* add the debug-only sources */ } // // crafter-build builds this module for the host and links it into the // consumer's project.so, the way Crafter.LintRules gets in, so Use runs each @@ -164,9 +165,12 @@ export namespace Crafter::Debug { }; // Adds Crafter.Debug to `cfg` when options.enabled, and writes the - // launcher; see the comment at the top. `at` defaults to the call site, - // which is how Use finds the consumer's directory. - void Use(Configuration& cfg, const UseOptions& options, std::source_location at = std::source_location::current()) { + // launcher; see the comment at the top. Returns whether it added the + // library and the define, which is when the consumer adds its debug-only + // sources: not for every build with the flag, since a target other than + // native Linux gets neither. `at` defaults to the call site, which is how + // Use finds the consumer's directory. + bool Use(Configuration& cfg, const UseOptions& options, std::source_location at = std::source_location::current()) { const fs::path launcher = fs::path(at.file_name()).parent_path() / "build" / "crafter-debug"; // Only for the project crafter-build was pointed at: a consumer loaded // as someone else's dependency has no agent driving it from its own @@ -174,17 +178,18 @@ export namespace Crafter::Debug { const bool topLevel = IsTopLevelProject(); if (!options.enabled || !NativeLinux(cfg.target)) { if (topLevel && !fs::exists(launcher)) WriteIfChanged(launcher, StubText(options.flag)); - return; + return false; } Configuration* library = LocalProject({ .projectFile = Checkout() / "project.cpp", .args = options.args }); cfg.dependencies.push_back(library); if (!options.define.empty()) cfg.defines.push_back({ options.define, "" }); - if (!topLevel) return; + if (!topLevel) return true; const auto client = std::ranges::find(library->dependencies, std::string_view("crafter.debug-cli"), [](const Configuration* dependency) { return std::string_view(dependency->name); }); if (client == library->dependencies.end()) { throw std::runtime_error(std::format("Crafter.Debug: {} declares no crafter.debug-cli client", (Checkout() / "project.cpp").string())); } WriteIfChanged(launcher, LauncherText((*client)->BinDir() / (*client)->outputName, CurrentSandbox(), options.flag)); + return true; } } From 4a0d911e69ffed63ebc0246837a4b7d1299f7e6a Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Sun, 1 Nov 2026 03:22:17 +0100 Subject: [PATCH 09/10] feat(examples): Basic, a ball simulation with a bug to find The example was a placeholder that printed which build it was. It is now a headless simulation of five balls dropped onto a 10 cm floor slab at 60 ticks a second, with a bug planted the way such bugs are written: collision is checked only where each step lands, so the ball dropped from 60 m, at 34 m/s, steps over the slab and falls forever. It shows the consumer side end to end: - Basic.DebugShim forwards Pump, Advance, QuitRequested, Log, Check, Emit and Sample to Crafter.Debug in debug builds and does nothing in plain ones, so the simulation and main have no #ifdef but the session's. - Basic.DebugCommands, compiled only when Debug::Use returns true, registers World_Spawn, World_Reset, World_Clear and Ball_Get, and watches for the physics, the floor and the balls. - The simulation checks each ball stays above the floor, emits Bounce and samples the fastest ball, so `pause --on-check`, `wait --event` and `trace --history` have something to show. Driven over the real transport, it shows the whole loop: `log --level warn` names the ball, `pause --on-check` stops the frame it fails, `set physics.substeps 4` and `call World_Reset` show the fix holds before any code changes. The plain build simulates ten seconds and prints that one ball fell through. Its agent notes describe the app without the bug. Co-Authored-By: Claude Opus 5.5 --- examples/Basic/AGENTS.template.md | 16 ++++- .../implementations/Basic.DebugCommands.cpp | 51 ++++++++++++++ .../Basic/implementations/Basic.World.cpp | 65 ++++++++++++++++++ examples/Basic/implementations/main.cpp | 52 +++++++++++++-- .../Basic/interfaces/Basic.DebugCommands.cppm | 14 ++++ .../Basic/interfaces/Basic.DebugShim.cppm | 26 ++++++++ examples/Basic/interfaces/Basic.World.cppm | 66 +++++++++++++++++++ examples/Basic/project.cpp | 11 +++- 8 files changed, 292 insertions(+), 9 deletions(-) create mode 100644 examples/Basic/implementations/Basic.DebugCommands.cpp create mode 100644 examples/Basic/implementations/Basic.World.cpp create mode 100644 examples/Basic/interfaces/Basic.DebugCommands.cppm create mode 100644 examples/Basic/interfaces/Basic.DebugShim.cppm create mode 100644 examples/Basic/interfaces/Basic.World.cppm diff --git a/examples/Basic/AGENTS.template.md b/examples/Basic/AGENTS.template.md index 246dd41..cea1504 100644 --- a/examples/Basic/AGENTS.template.md +++ b/examples/Basic/AGENTS.template.md @@ -1,3 +1,17 @@ # Basic -A Crafter.Debug example. Build with `crafter-build --debug-api` to include the debug server. +A Crafter.Debug example: a headless simulation of balls dropped onto a floor slab, 60 ticks a second. + +- **Plain build:** `crafter-build`, run with `crafter-build -r`. It simulates ten seconds as fast as it can and prints how many balls ended up below the floor. +- **Debug build:** this project's debug flag is `--debug-api` and its define `BASIC_DEBUG_API`. The build adds Crafter.Debug, and the app runs in real time until an agent tells it to quit. + +## Layout + +- `interfaces/Basic.World.cppm`, `implementations/Basic.World.cpp`: the simulation. +- `interfaces/Basic.DebugShim.cppm`: Crafter.Debug's frame-loop and timeline calls, in every build. In the plain build they are stand-ins that do nothing, so the simulation and `main` need no `#ifdef`. +- `interfaces/Basic.DebugCommands.cppm`, `implementations/Basic.DebugCommands.cpp`: the commands and watches, compiled only when `Debug::Use` added Crafter.Debug. +- `implementations/main.cpp`: the loop, with the session under the define. + +## Working on Crafter.Debug from here + +`project.cpp` loads Crafter.Debug from this checkout (`#pragma crafter dependency "../.."`). Crafter.Network's callback API, which the transport needs, may not be on Network's `master` yet. If it isn't, build with `CRAFTER_DEBUG_NETWORK=/project.cpp`. diff --git a/examples/Basic/implementations/Basic.DebugCommands.cpp b/examples/Basic/implementations/Basic.DebugCommands.cpp new file mode 100644 index 0000000..83a84ac --- /dev/null +++ b/examples/Basic/implementations/Basic.DebugCommands.cpp @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +module Basic.DebugCommands; +import std; +import Crafter.Debug; +import Basic.World; +namespace Debug = Crafter::Debug; + +namespace Basic { + struct SpawnArgs { + Debug::Required y; + float x = 0; + float vy = 0; + float radius = 0.1f; + }; + + struct BallArgs { + Debug::Required id; + }; +} + +void Basic::RegisterDebug(World& world) { + // Commands run on the main thread, between ticks, so they touch the + // world as the simulation does. + Debug::Command("World_Spawn", "Drop a ball from height y (m), moving at vy (m/s, up is positive); returns its id.", + [&world](const SpawnArgs& args) -> Debug::Result { + if (args.radius <= 0) return Debug::Error{ "BadRadius", "radius must be above 0" }; + if (*args.y - args.radius <= world.floor.top) { + return Debug::Error{ "BelowFloor", std::format("y must clear the floor's top ({} m) by the radius", world.floor.top) }; + } + return world.Spawn(args.x, *args.y, args.vy, args.radius); + }); + Debug::Command("World_Reset", "Remove every ball and drop the scene's five again.", [&world] { world.Reset(); }); + Debug::Command("World_Clear", "Remove every ball.", [&world] { world.Clear(); }); + Debug::Command("Ball_Get", "One ball's position, velocity and state.", [&world](const BallArgs& args) -> Debug::Result { + if (const Ball* ball = world.Find(*args.id)) return *ball; + return Debug::Error{ "NoBall", std::format("no ball {}; `get balls` lists them", *args.id) }; + }); + + Debug::Watch("physics.gravity", world.physics.gravity, { .min = -50, .max = 0, .help = "m/s^2" }); + Debug::Watch("physics.restitution", world.physics.restitution, { .min = 0, .max = 1, .help = "share of speed kept by a bounce" }); + Debug::Watch("physics.substeps", world.physics.substeps, { .min = 1, .max = 16, .help = "collision checks per tick" }); + Debug::Watch("floor.top", world.floor.top, { .help = "m" }); + Debug::Watch("floor.thickness", world.floor.thickness, { .min = 0.01, .max = 10, .help = "m" }); + Debug::Watch("world.time", [&world] { return world.Time(); }, { .help = "simulated seconds since the last reset" }); + Debug::Watch("world.count", [&world] { return world.Balls().size(); }, { .help = "balls in the scene" }); + Debug::Watch("world.lost", [&world] { return world.Lost(); }, { .help = "balls below the floor" }); + Debug::Watch("world.fastest", [&world] { return world.Fastest(); }, { .help = "m/s, last tick" }); + Debug::Watch("balls", [&world] { return std::vector(world.Balls().begin(), world.Balls().end()); }); +} diff --git a/examples/Basic/implementations/Basic.World.cpp b/examples/Basic/implementations/Basic.World.cpp new file mode 100644 index 0000000..14459f5 --- /dev/null +++ b/examples/Basic/implementations/Basic.World.cpp @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +module Basic.World; +import std; +import Basic.DebugShim; +namespace Debug = Crafter::Debug; + +void Basic::World::Reset() { + Clear(); + constexpr std::array heights = { 1, 3, 8, 20, 60 }; + for (std::size_t i = 0; i < heights.size(); ++i) { + Spawn(static_cast(i) - 2.0f, heights[i], 0, 0.1f); + } +} + +void Basic::World::Clear() { + balls.clear(); + time = 0; + fastest = 0; +} + +std::int32_t Basic::World::Spawn(float x, float y, float vy, float radius) { + const std::int32_t id = nextId++; + balls.push_back({ .id = id, .x = x, .y = y, .vy = vy, .radius = radius }); + Debug::Log("world", "ball {} dropped from {:.1f} m", id, y); + return id; +} + +void Basic::World::Tick(float dt) { + const std::int32_t steps = std::max(1, physics.substeps); + const float h = dt / static_cast(steps); + const float bottom = floor.top - floor.thickness; + fastest = 0; + for (Ball& ball : balls) { + for (std::int32_t step = 0; step < steps; ++step) { + ball.vy += physics.gravity * h; + ball.x += ball.vx * h; + ball.y += ball.vy * h; + if (ball.lost) continue; + // A falling ball that overlaps the slab bounces off its top. + if (ball.vy < 0 && ball.y - ball.radius < floor.top && ball.y + ball.radius > bottom) { + const float speed = -ball.vy; + ball.y = floor.top + ball.radius; + ball.vy = speed * physics.restitution; + if (speed > 1) Debug::Emit("Bounce", BounceEvent{ .ball = ball.id, .speed = speed }); + } + } + if (!ball.lost && !Debug::Check(ball.y + ball.radius >= bottom, "physics", "ball {} is below the floor: y {:.2f} m, vy {:.1f} m/s", ball.id, ball.y, ball.vy)) { + ball.lost = true; + } + fastest = std::max(fastest, std::hypot(ball.vx, ball.vy)); + } + Debug::Sample("solver.fastest", fastest); + time += dt; +} + +const Basic::Ball* Basic::World::Find(std::int32_t id) const { + const auto found = std::ranges::find(balls, id, &Ball::id); + return found == balls.end() ? nullptr : &*found; +} + +std::int32_t Basic::World::Lost() const { + return static_cast(std::ranges::count(balls, true, &Ball::lost)); +} diff --git a/examples/Basic/implementations/main.cpp b/examples/Basic/implementations/main.cpp index a931796..0118182 100644 --- a/examples/Basic/implementations/main.cpp +++ b/examples/Basic/implementations/main.cpp @@ -1,16 +1,58 @@ // SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// A headless simulation at 60 ticks a second. The plain build simulates ten +// seconds as fast as it can and prints what happened; the debug build runs in +// real time, serving Crafter.Debug, until an agent says quit. import std; +import Basic.DebugShim; +import Basic.World; #ifdef BASIC_DEBUG_API -import Crafter.Debug; +import Crafter.Thread; +import Basic.DebugCommands; #endif +namespace Debug = Crafter::Debug; +using Clock = std::chrono::steady_clock; + +namespace { + constexpr float dt = 1.0f / 60.0f; + +#ifdef BASIC_DEBUG_API + constexpr bool realTime = true; +#else + constexpr bool realTime = false; +#endif +} int main() { + Basic::World world; + world.Reset(); + #ifdef BASIC_DEBUG_API - std::println("basic: debug build, Crafter.Debug {}", Crafter::Debug::Version()); -#else - std::println("basic: plain build"); + // The session's transport runs on Crafter.Thread's pool. An app that + // already uses the pool starts it as usual; this one only needs it here. + Crafter::ThreadPool::Start(); + Debug::Session debug({ .name = "basic" }); + Basic::RegisterDebug(world); + std::println("basic: running; drive it with ./build/crafter-debug, stop it with ./build/crafter-debug quit"); #endif - return 0; + + const auto tick = std::chrono::duration_cast(std::chrono::duration(dt)); + Clock::time_point next = Clock::now(); + while (!Debug::QuitRequested() && (realTime || world.Time() < 10.0)) { + Debug::Pump(); + if (Debug::Advance()) world.Tick(dt); + if constexpr (realTime) { + next += tick; + // After a stop in a debugger, carry on from now instead of + // racing to catch up. + if (Clock::now() - next > std::chrono::seconds(1)) next = Clock::now(); + std::this_thread::sleep_until(next); + } + } + + std::println("basic: {:.1f} s simulated; {} balls, {} below the floor", world.Time(), world.Balls().size(), world.Lost()); + for (const Basic::Ball& ball : world.Balls()) { + if (ball.lost) std::println(" ball {} fell through the floor", ball.id); + } } diff --git a/examples/Basic/interfaces/Basic.DebugCommands.cppm b/examples/Basic/interfaces/Basic.DebugCommands.cppm new file mode 100644 index 0000000..da9c73d --- /dev/null +++ b/examples/Basic/interfaces/Basic.DebugCommands.cppm @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// What an agent can call and read in a debug build. project.cpp compiles +// this module only when Debug::Use added Crafter.Debug, so the plain build +// has none of it. +export module Basic.DebugCommands; +import Basic.World; + +export namespace Basic { + // Registers the commands and watches on `world`, which must outlive the + // Session. + void RegisterDebug(World& world); +} diff --git a/examples/Basic/interfaces/Basic.DebugShim.cppm b/examples/Basic/interfaces/Basic.DebugShim.cppm new file mode 100644 index 0000000..ec79d73 --- /dev/null +++ b/examples/Basic/interfaces/Basic.DebugShim.cppm @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// Crafter.Debug's frame-loop and timeline calls, in every build: the real +// ones under --debug-api, and in a plain build stand-ins that do nothing, so +// the simulation and main need no #ifdef around them. This is one of the +// app's own sources, so the define Debug::Use adds reaches it. +export module Basic.DebugShim; +import std; +#ifdef BASIC_DEBUG_API +export import Crafter.Debug; +#else +export namespace Crafter::Debug { + inline void Pump() {} + inline bool Advance() { return true; } + inline bool Paused() { return false; } + inline std::uint32_t TakeSteps() { return 0; } + inline bool QuitRequested() { return false; } + template void Log(A&&...) {} + template void LogWarn(A&&...) {} + template void LogError(A&&...) {} + template bool Check(bool ok, A&&...) { return ok; } + template void Emit(A&&...) {} + template void Sample(std::string_view, const T&) {} +} +#endif diff --git a/examples/Basic/interfaces/Basic.World.cppm b/examples/Basic/interfaces/Basic.World.cppm new file mode 100644 index 0000000..3ec2b06 --- /dev/null +++ b/examples/Basic/interfaces/Basic.World.cppm @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// Balls dropped onto a floor slab, in two dimensions, at a fixed step. y is +// up, in metres; the slab's top is at floor.top and it is floor.thickness +// thick. +export module Basic.World; +import std; + +export namespace Basic { + struct Ball { + std::int32_t id = 0; + float x = 0; + float y = 0; + float vx = 0; + float vy = 0; + float radius = 0.1f; + // Below the floor, and so out of the scene for good. + bool lost = false; + }; + + struct Physics { + float gravity = -9.81f; + // The share of its speed a ball keeps when it bounces. + float restitution = 0.6f; + // Collision checks per tick. + std::int32_t substeps = 1; + }; + + struct Floor { + float top = 0; + float thickness = 0.1f; + }; + + // What `wait --event Bounce` waits for, sent for bounces faster than 1 m/s. + struct BounceEvent { + std::int32_t ball = 0; + float speed = 0; + }; + + class World { + public: + Physics physics; + Floor floor; + + // Removes every ball and drops the scene's five. + void Reset(); + void Clear(); + // Returns the new ball's id. + std::int32_t Spawn(float x, float y, float vy, float radius); + void Tick(float dt); + + const std::deque& Balls() const { return balls; } + const Ball* Find(std::int32_t id) const; + double Time() const { return time; } + std::int32_t Lost() const; + // The fastest ball's speed in the last tick, in m/s. + float Fastest() const { return fastest; } + + private: + std::deque balls; + std::int32_t nextId = 1; + double time = 0; + float fastest = 0; + }; +} diff --git a/examples/Basic/project.cpp b/examples/Basic/project.cpp index dba5ef5..c3fc25d 100644 --- a/examples/Basic/project.cpp +++ b/examples/Basic/project.cpp @@ -24,10 +24,15 @@ extern "C" Configuration CrafterBuildProject(std::span a for (const std::string_view arg : args) { if (arg != "--debug-api") depArgs.emplace_back(arg); } - Debug::Use(cfg, { .enabled = opts.Has("--debug-api"), .flag = "--debug-api", .define = "BASIC_DEBUG_API", .args = depArgs }); + const bool debugApi = Debug::Use(cfg, { .enabled = opts.Has("--debug-api"), .flag = "--debug-api", .define = "BASIC_DEBUG_API", .args = depArgs }); - std::array interfaces = {}; - std::array implementations = { "implementations/main" }; + std::vector interfaces = { "interfaces/Basic.DebugShim", "interfaces/Basic.World" }; + std::vector implementations = { "implementations/Basic.World", "implementations/main" }; + // The agent's commands and watches, in debug builds only. + if (debugApi) { + interfaces.emplace_back("interfaces/Basic.DebugCommands"); + implementations.emplace_back("implementations/Basic.DebugCommands"); + } cfg.GetInterfacesAndImplementations(interfaces, implementations); return cfg; } From 653813e1e39a5edab42bee006fc921765ebb3635 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Sun, 1 Nov 2026 18:04:22 +0100 Subject: [PATCH 10/10] chore(transport): drop the CRAFTER_DEBUG_NETWORK hook The transport needed Crafter.Network's callback API, which lived on an unpushed branch, so project.cpp took Network from whatever project.cpp $CRAFTER_DEBUG_NETWORK named. That API and the ~ClientQUIC fix are on Network's master now (79a2f12), so the hook goes: Network comes from forgejo, unpinned, or from ../Crafter.Network with --local, as before. The notes and the README stop telling people to set it, and say which Network the transport needs. Every test passes with Network cloned from forgejo, and the example builds and is driven end to end that way. Co-Authored-By: Claude Opus 5.5 --- AGENTS.template.md | 6 +++--- README.md | 2 +- examples/Basic/AGENTS.template.md | 2 +- project.cpp | 6 +----- 4 files changed, 6 insertions(+), 10 deletions(-) diff --git a/AGENTS.template.md b/AGENTS.template.md index cc076dc..8de5ac4 100644 --- a/AGENTS.template.md +++ b/AGENTS.template.md @@ -36,9 +36,9 @@ Tests run inside the Flatpak sandbox, where: Every `flatpak run` is its own sandbox with its own pid namespace. -**Crafter.Network** comes from forgejo, unpinned, so every load `git pull`s it. `--local` takes `../Crafter.Network`, and `CRAFTER_DEBUG_NETWORK=` takes any checkout, a worktree say. The environment reaches the build inside the Flatpak. +**Crafter.Network** comes from forgejo, unpinned, so every load `git pull`s it. `--local` takes `../Crafter.Network` instead. -The transport needs Network's callback API (`OnRecieve`, `SendAsync`, `OnClosed`, `ListenerQUIC` with a bind address and `Port()`) and its fix that lets stopped streams finish before `~ClientQUIC` closes. Until those reach Network's `master`, build with `CRAFTER_DEBUG_NETWORK` pointing at a checkout of the branch that has them. +The transport needs Network's callback API (`OnRecieve`, `SendAsync`, `OnClosed`, `ListenerQUIC` with a bind address and `Port()`) and its fix that lets stopped streams finish before `~ClientQUIC` closes. Both are on Network's `master` from 79a2f12 on, so a `../Crafter.Network` older than that does not build the transport. ## Build and test commands @@ -46,7 +46,7 @@ The transport needs Network's callback API (`OnRecieve`, `SendAsync`, `OnClosed` flatpak run --die-with-parent net.catcrafts.CrafterBuild # the library, core, client flatpak run --die-with-parent net.catcrafts.CrafterBuild test # every test flatpak run --die-with-parent net.catcrafts.CrafterBuild test 'Should*Client*' -CRAFTER_DEBUG_NETWORK=../Crafter.Network-quic-callbacks/project.cpp flatpak run --die-with-parent net.catcrafts.CrafterBuild test # Network from a worktree +flatpak run --die-with-parent net.catcrafts.CrafterBuild test --local # with ../Crafter.Network cd examples/Basic && flatpak run --die-with-parent net.catcrafts.CrafterBuild --debug-api # a consumer's debug build ``` diff --git a/README.md b/README.md index 817e5b5..5481d6b 100644 --- a/README.md +++ b/README.md @@ -324,7 +324,7 @@ cd examples/Basic && crafter-build --debug-api -r - **Library and core tests** run in-process, against a `Session` built with `listen = false` and a fake transport. - **`ShouldRunClientCommands`** runs the real client against a real session that way. - **`ShouldServeOverQuic`** runs a listening session and drives it over loopback with the client's own QUIC transport. It covers discovery, calls, `log --follow`, wrong tokens, oversized requests, parallel requests, and shutdown with a client connected. -- **Crafter.Network** comes from forgejo, or from `../Crafter.Network` with `--local`. To build against another checkout, a worktree say, set `CRAFTER_DEBUG_NETWORK` to its `project.cpp`. +- **Crafter.Network** comes from forgejo, or from `../Crafter.Network` with `--local`. - **`AGENTS.template.md`** has the conventions. Licensed LGPL-3.0-only; the examples are MIT. diff --git a/examples/Basic/AGENTS.template.md b/examples/Basic/AGENTS.template.md index cea1504..058abd3 100644 --- a/examples/Basic/AGENTS.template.md +++ b/examples/Basic/AGENTS.template.md @@ -14,4 +14,4 @@ A Crafter.Debug example: a headless simulation of balls dropped onto a floor sla ## Working on Crafter.Debug from here -`project.cpp` loads Crafter.Debug from this checkout (`#pragma crafter dependency "../.."`). Crafter.Network's callback API, which the transport needs, may not be on Network's `master` yet. If it isn't, build with `CRAFTER_DEBUG_NETWORK=/project.cpp`. +`project.cpp` loads Crafter.Debug from this checkout (`#pragma crafter dependency "../.."`), so a change there shows up in the next build here. diff --git a/project.cpp b/project.cpp index be39bc3..6e03b38 100644 --- a/project.cpp +++ b/project.cpp @@ -14,8 +14,7 @@ // Debug::Use (tooling/), which also writes their launcher. // // The library and the client talk QUIC through Crafter.Network, which comes -// from forgejo, with --local from the checkout beside this one, or from the -// project.cpp $CRAFTER_DEBUG_NETWORK names (a Network worktree). +// from forgejo, or with --local from the checkout beside this one. import std; import Crafter.Build; namespace fs = std::filesystem; @@ -39,9 +38,6 @@ namespace { // than one module built twice. Configuration* Network(std::span args) { const std::vector forwarded(args.begin(), args.end()); - if (const char* local = std::getenv("CRAFTER_DEBUG_NETWORK"); local != nullptr && *local != '\0') { - return LocalProject({ .projectFile = local, .args = forwarded }); - } if (std::ranges::find(args, std::string_view("--local")) != args.end()) { return LocalProject({ .projectFile = Root().parent_path() / "Crafter.Network" / "project.cpp", .args = forwarded }); }