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] 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); } {