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:
parent
47f2325664
commit
f8024b7a43
5 changed files with 204 additions and 19 deletions
163
implementations/Crafter.Debug.Cli-Quic.cpp
Normal file
163
implementations/Crafter.Debug.Cli-Quic.cpp
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
std::int32_t code = 0;
|
||||||
|
{
|
||||||
Cli::Environment environment;
|
Cli::Environment environment;
|
||||||
environment.connect = [](const InstanceRecord&) -> std::unique_ptr<Cli::Connection> { return std::make_unique<Unbuilt>(); };
|
environment.connect = Cli::ConnectQuic;
|
||||||
environment.haveLldb = [] { return std::filesystem::exists("/app/bin/lldb") || std::filesystem::exists("/usr/bin/lldb"); };
|
environment.haveLldb = [] { return std::filesystem::exists("/app/bin/lldb") || std::filesystem::exists("/usr/bin/lldb"); };
|
||||||
return Cli::Run(args, environment);
|
// 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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
16
interfaces/Crafter.Debug.Cli-Quic.cppm
Normal file
16
interfaces/Crafter.Debug.Cli-Quic.cppm
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue