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>
163 lines
7 KiB
C++
163 lines
7 KiB
C++
// 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);
|
|
}
|