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 <noreply@anthropic.com>
This commit is contained in:
Jorijn van der Graaf 2026-11-01 03:07:09 +01:00
commit f8024b7a43
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
5 changed files with 204 additions and 19 deletions

View file

@ -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<std::string> lines;
bool ended = false;
bool clean = false;
void Add(std::span<const char> 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<Value, CallError> Call(const Value& request, std::chrono::milliseconds timeout,
const std::function<bool(const Value& line)>& onLine) override {
const Clock::time_point deadline = Clock::now() + timeout;
if (!client) {
try {
client = std::make_unique<ClientQUIC>(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<ClientQUIC> client;
std::expected<Value, CallError> Send(const Value& request, Clock::time_point deadline,
const std::function<bool(const Value& line)>& onLine) {
const auto inbox = std::make_shared<Inbox>();
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<const char> data) { inbox->Add(data); },
[inbox](bool clean) { inbox->End(clean); });
std::string text = request.Dump();
text += '\n';
try {
stream.SendSync(text.data(), static_cast<std::uint32_t>(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<Connection> Crafter::Debug::Cli::ConnectQuic(const InstanceRecord& instance) {
return std::make_unique<QuicConnection>(instance.port);
}

View file

@ -1,26 +1,30 @@
// SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // 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 std;
import Crafter.Thread;
import Crafter.Debug.Core; import Crafter.Debug.Core;
import Crafter.Debug.Cli; import Crafter.Debug.Cli;
using namespace Crafter::Debug; using namespace Crafter::Debug;
namespace {
// Until the QUIC transport lands: every call reports it unreachable.
class Unbuilt : public Cli::Connection {
public:
std::expected<Value, Cli::CallError> Call(const Value&, std::chrono::milliseconds, const std::function<bool(const Value&)>&) override {
return std::unexpected(Cli::CallError{ Cli::CallError::Kind::Unreachable, "this client was built without its transport" });
}
};
}
int main(int argc, char** argv) { 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<std::string_view> args(argv + 1, argv + argc); const std::vector<std::string_view> args(argv + 1, argv + argc);
Cli::Environment environment; std::int32_t code = 0;
environment.connect = [](const InstanceRecord&) -> std::unique_ptr<Cli::Connection> { return std::make_unique<Unbuilt>(); }; {
environment.haveLldb = [] { return std::filesystem::exists("/app/bin/lldb") || std::filesystem::exists("/usr/bin/lldb"); }; Cli::Environment environment;
return Cli::Run(args, 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);
} }

View file

@ -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<Connection> ConnectQuic(const InstanceRecord& instance);
}

View file

@ -1,6 +1,8 @@
// SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // 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 module Crafter.Debug.Cli;
export import :Client; export import :Client;
export import :Quic;

View file

@ -172,7 +172,7 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
cli.mtune = cfg.mtune; cli.mtune = cfg.mtune;
cli.sysroot = cfg.sysroot; cli.sysroot = cfg.sysroot;
cli.debug = cfg.debug; cli.debug = cfg.debug;
cli.dependencies = { &core }; cli.dependencies = { &core, network };
client.dependencies = { &cli }; client.dependencies = { &cli };
{ {
@ -196,8 +196,8 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
core.GetInterfacesAndImplementations(interfaces, implementations); core.GetInterfacesAndImplementations(interfaces, implementations);
} }
{ {
std::array<fs::path, 2> interfaces = { "interfaces/Crafter.Debug.Cli-Client", "interfaces/Crafter.Debug.Cli" }; std::array<fs::path, 3> interfaces = { "interfaces/Crafter.Debug.Cli-Client", "interfaces/Crafter.Debug.Cli-Quic", "interfaces/Crafter.Debug.Cli" };
std::array<fs::path, 1> implementations = { "implementations/Crafter.Debug.Cli-Client" }; std::array<fs::path, 2> implementations = { "implementations/Crafter.Debug.Cli-Client", "implementations/Crafter.Debug.Cli-Quic" };
cli.GetInterfacesAndImplementations(interfaces, implementations); cli.GetInterfacesAndImplementations(interfaces, implementations);
} }
{ {