feat(seatpool): the seat pool and the board several drivers share
The seats of .env and the ledger, the probe, placement, settling a finished pass, the child's credentials, the OpenRouter seats, and the board every driver on the machine shares, in one shape and under one lock. A driver is alive while its lifetime lock is held; with no lock file natively its pid and start time decide, and in the sandbox it is taken for alive. :Http is the HTTP the driver needs, on FetchHTTP1: a status outside 2xx is HttpError, every other failure carries a fixed text. :Platform gains MakeDirs, with the last directory's own mode, and errno texts for its writes and locks. The tests cover the seat pool and several drivers at once, less the checks that drive the pool's Scheduler, Admission and Stacks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
8ffbd6cf5d
commit
c63c898833
10 changed files with 2562 additions and 8 deletions
108
implementations/Crafter.Review-Http.cpp
Normal file
108
implementations/Crafter.Review-Http.cpp
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
module;
|
||||
#include <netdb.h>
|
||||
|
||||
module Crafter.Review:Http_impl;
|
||||
import :Http;
|
||||
import :Json;
|
||||
import :Text;
|
||||
import Crafter.Network;
|
||||
import std;
|
||||
|
||||
namespace Crafter::Review::Http {
|
||||
namespace {
|
||||
std::string Wrapped(std::string_view reason) {
|
||||
return std::format("<urlopen error {}>", reason);
|
||||
}
|
||||
|
||||
// ClientTCP's resolve, socket() and connect failures, which happen
|
||||
// while the request goes out, apart from a send's or a receive's.
|
||||
bool WhileSending(const SocketErrorException& failure) {
|
||||
const std::string_view text = failure.what();
|
||||
return failure.kind == SocketErrorKind::Resolver || text.starts_with("Could not connect") || text.starts_with("Could not open socket");
|
||||
}
|
||||
|
||||
std::string SocketText(const SocketErrorException& failure) {
|
||||
if (failure.kind == SocketErrorKind::Resolver) return std::format("[Errno {}] {}", failure.code, gai_strerror(failure.code));
|
||||
return Dyn::ErrnoText(failure.code);
|
||||
}
|
||||
|
||||
constexpr std::array<std::pair<std::int32_t, std::string_view>, 62> Phrases = {{
|
||||
{100, "Continue"}, {101, "Switching Protocols"}, {102, "Processing"}, {103, "Early Hints"}, {200, "OK"}, {201, "Created"}, {202, "Accepted"},
|
||||
{203, "Non-Authoritative Information"}, {204, "No Content"}, {205, "Reset Content"}, {206, "Partial Content"}, {207, "Multi-Status"},
|
||||
{208, "Already Reported"}, {226, "IM Used"}, {300, "Multiple Choices"}, {301, "Moved Permanently"}, {302, "Found"}, {303, "See Other"},
|
||||
{304, "Not Modified"}, {305, "Use Proxy"}, {307, "Temporary Redirect"}, {308, "Permanent Redirect"}, {400, "Bad Request"}, {401, "Unauthorized"},
|
||||
{402, "Payment Required"}, {403, "Forbidden"}, {404, "Not Found"}, {405, "Method Not Allowed"}, {406, "Not Acceptable"},
|
||||
{407, "Proxy Authentication Required"}, {408, "Request Timeout"}, {409, "Conflict"}, {410, "Gone"}, {411, "Length Required"},
|
||||
{412, "Precondition Failed"}, {413, "Content Too Large"}, {414, "URI Too Long"}, {415, "Unsupported Media Type"}, {416, "Range Not Satisfiable"},
|
||||
{417, "Expectation Failed"}, {418, "I'm a Teapot"}, {421, "Misdirected Request"}, {422, "Unprocessable Content"}, {423, "Locked"},
|
||||
{424, "Failed Dependency"}, {425, "Too Early"}, {426, "Upgrade Required"}, {428, "Precondition Required"}, {429, "Too Many Requests"},
|
||||
{431, "Request Header Fields Too Large"}, {451, "Unavailable For Legal Reasons"}, {500, "Internal Server Error"}, {501, "Not Implemented"},
|
||||
{502, "Bad Gateway"}, {503, "Service Unavailable"}, {504, "Gateway Timeout"}, {505, "HTTP Version Not Supported"}, {506, "Variant Also Negotiates"},
|
||||
{507, "Insufficient Storage"}, {508, "Loop Detected"}, {510, "Not Extended"}, {511, "Network Authentication Required"},
|
||||
}};
|
||||
}
|
||||
|
||||
HttpError::HttpError(std::int32_t code, const std::string& message) : std::runtime_error(std::format("HTTP Error {}: {}", code, message)), code(code) {}
|
||||
|
||||
HttpError::HttpError(std::int32_t code) : HttpError(code, std::string(Reason(code))) {}
|
||||
|
||||
std::int32_t HttpError::Code() const {
|
||||
return code;
|
||||
}
|
||||
|
||||
std::string_view Reason(std::int32_t code) {
|
||||
for (const auto& [value, phrase] : Phrases) {
|
||||
if (value == code) return phrase;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
Response UrlOpen(std::string_view url, const std::map<std::string, std::string>& headers, double timeout) {
|
||||
FetchOptionsHTTP1 options;
|
||||
options.headers = {{"accept-encoding", "identity"}, {"connection", "close"}, {"user-agent", "crafter-review"}};
|
||||
for (const auto& [name, value] : headers) options.headers[Dyn::Lower(name)] = value;
|
||||
// The timeout bounds each blocking step: the connect, the handshake,
|
||||
// every read.
|
||||
const std::chrono::milliseconds budget(static_cast<std::int64_t>(timeout * 1000));
|
||||
options.connectTimeout = budget;
|
||||
options.handshakeTimeout = budget;
|
||||
options.timeout = budget;
|
||||
FetchResultHTTP1 result;
|
||||
try {
|
||||
result = FetchHTTP1(url, options);
|
||||
} catch (const SocketTimeoutException&) {
|
||||
throw Error(Wrapped("timed out"));
|
||||
} catch (const SocketErrorException& failure) {
|
||||
throw Error(WhileSending(failure) ? Wrapped(SocketText(failure)) : SocketText(failure));
|
||||
} catch (const SocketClosedException&) {
|
||||
throw Error("Remote end closed connection without response");
|
||||
} catch (const TLSException& failure) {
|
||||
throw Error(Wrapped(failure.what()));
|
||||
} catch (const std::invalid_argument& failure) {
|
||||
throw Error(failure.what());
|
||||
} catch (const std::runtime_error& failure) {
|
||||
const std::string_view text = failure.what();
|
||||
if (text.starts_with("timed out")) throw Error("timed out");
|
||||
if (text.starts_with("connection closed")) throw Error("Remote end closed connection without response");
|
||||
throw Error(failure.what());
|
||||
} catch (const std::exception& failure) {
|
||||
throw Error(failure.what());
|
||||
}
|
||||
const std::optional<std::int64_t> status = Dyn::ParseInt(result.response.status);
|
||||
const auto code = static_cast<std::int32_t>(status.value_or(0));
|
||||
if (result.limited) {
|
||||
throw HttpError(code, std::format("The HTTP server returned a redirect error that would lead to an infinite loop.\nThe last 30x error message was:\n{}", Reason(code)));
|
||||
}
|
||||
if (code < 200 || code >= 300) throw HttpError(code);
|
||||
return {code, std::move(result.response.body)};
|
||||
}
|
||||
|
||||
Json JsonBody(const Response& response) {
|
||||
if (const std::optional<std::string> error = Dyn::Utf8DecodeError(response.body)) throw Dyn::Error("UnicodeDecodeError", *error);
|
||||
if (const std::optional<std::string> error = Dyn::JsonDecodeError(response.body)) throw Dyn::Error("JSONDecodeError", *error);
|
||||
return ParseLoose(response.body);
|
||||
}
|
||||
}
|
||||
|
|
@ -169,7 +169,7 @@ namespace Crafter::Review::Platform {
|
|||
if (path.has_parent_path()) fs::create_directories(path.parent_path());
|
||||
const fs::path temp = std::format("{}.{}.tmp", path.string(), PublishedPid());
|
||||
const std::int32_t fd = open(temp.c_str(), O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, static_cast<mode_t>(mode));
|
||||
if (fd < 0) throw std::runtime_error(std::format("{}: {}", temp.string(), Dyn::ErrnoText(errno)));
|
||||
if (fd < 0) throw std::runtime_error(std::format("{}: {}", Dyn::ErrnoText(errno), Dyn::Repr(temp.string())));
|
||||
std::size_t written = 0;
|
||||
while (written < content.size()) {
|
||||
const ssize_t step = write(fd, content.data() + written, content.size() - written);
|
||||
|
|
@ -178,7 +178,7 @@ namespace Crafter::Review::Platform {
|
|||
const std::int32_t failure = errno;
|
||||
close(fd);
|
||||
unlink(temp.c_str());
|
||||
throw std::runtime_error(std::format("{}: {}", temp.string(), Dyn::ErrnoText(failure)));
|
||||
throw std::runtime_error(Dyn::ErrnoText(failure));
|
||||
}
|
||||
written += static_cast<std::size_t>(step);
|
||||
}
|
||||
|
|
@ -186,7 +186,21 @@ namespace Crafter::Review::Platform {
|
|||
if (rename(temp.c_str(), path.c_str()) != 0) {
|
||||
const std::int32_t failure = errno;
|
||||
unlink(temp.c_str());
|
||||
throw std::runtime_error(std::format("{}: {}", path.string(), Dyn::ErrnoText(failure)));
|
||||
throw std::runtime_error(std::format("{}: {} -> {}", Dyn::ErrnoText(failure), Dyn::Repr(temp.string()), Dyn::Repr(path.string())));
|
||||
}
|
||||
}
|
||||
|
||||
void MakeDirs(const fs::path& path, std::uint32_t mode) {
|
||||
const fs::path name = path.filename().empty() && path.has_parent_path() ? path.parent_path() : path;
|
||||
const fs::path head = name.parent_path();
|
||||
std::error_code error;
|
||||
if (!head.empty() && head != name && !fs::exists(head, error)) {
|
||||
MakeDirs(head, 0777);
|
||||
if (name.filename() == ".") return;
|
||||
}
|
||||
if (mkdir(name.c_str(), static_cast<mode_t>(mode)) != 0) {
|
||||
const std::int32_t failure = errno;
|
||||
if (!fs::is_directory(name, error)) throw std::runtime_error(std::format("{}: {}", Dyn::ErrnoText(failure), Dyn::Repr(name.string())));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -195,7 +209,7 @@ namespace Crafter::Review::Platform {
|
|||
if (lockPath.has_parent_path()) fs::create_directories(lockPath.parent_path(), error);
|
||||
fd = open(lockPath.c_str(), O_WRONLY | O_CREAT | O_APPEND | O_CLOEXEC, 0666);
|
||||
if (fd < 0) {
|
||||
problem = Dyn::ErrnoText(errno);
|
||||
problem = std::format("{}: {}", Dyn::ErrnoText(errno), Dyn::Repr(lockPath.string()));
|
||||
return;
|
||||
}
|
||||
while (flock(fd, LOCK_EX) != 0) {
|
||||
|
|
@ -230,7 +244,7 @@ namespace Crafter::Review::Platform {
|
|||
}
|
||||
Json data;
|
||||
try {
|
||||
if (const std::optional<std::string> text = ReadFile(path)) data = Json::parse(*text);
|
||||
if (const std::optional<std::string> text = ReadFile(path)) data = ParseLoose(*text);
|
||||
} catch (const std::exception&) {
|
||||
data = Json();
|
||||
}
|
||||
|
|
|
|||
1498
implementations/Crafter.Review-SeatPool.cpp
Normal file
1498
implementations/Crafter.Review-SeatPool.cpp
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue