Generalizes the PR #23 guest observation plumbing into a schema-agnostic specialized-channel layer while keeping all harness policy out of core. Generalized admission (F2/HIGH): - New channel_descriptor/channel_host interface (include/glove/control/ guest_channel.hpp): hosts register {channel_id, schema_id, body_validator, bounds{max_items, max_body_bytes, max_ttl_ms, max_skew_ms}, on_exchange} at registry construction; core keeps only structural invariants (identifier charset, digest hex, TTL/skew arithmetic, capacity, idempotency) and delegates body semantics to registered validators. - The closed two-schema check in session_registry internals is removed: glove_observation_schema, glove_sxxx_proposal_* constants and the fixed SXXX digest move out of session_registry_impl.hpp. The Sage schemas are now registered by the harness adapter (apple_detail::sage_guest_channel_host) and gloved_main wires the host into the registry; schema strings appear only in harness-adapter and test-fixture registration examples. - enqueue_observation_intent binds intents to the session's PARSED runtime_id; the hardcoded sage_guest_runtime equality checks are removed from session_registry.cpp, session_registry_recovery.cpp and valid_record_shape. Replay of a now-unregistered schema fails closed (open_or_create rejects stores carrying unadmitted intents), and enqueue without an admission table is rejected. The observation socket server takes runtime_id from configuration instead of hardcoding "sage-guest". The observation body hash domain becomes the harness-neutral "glove.observation-body.v1" (pre-merge delta; no on-disk compatibility is preserved). Recovery fix (F1/HIGH): - same_session_snapshot now neutralizes state, running_at_ms and policy_revision before comparing frozen intent/disposition snapshots against the bound session record, so lifecycle progression and policy revision advances never read as a binding crossing; every other frozen-field difference still does. Exposed for tests. Regression tests cover running->stopping and stopping->exited transitions plus a late post-exit disposition end to end (destroy/reopen), and a snapshot-identity unit test pins the neutralization (and proves non-neutralized fields still cross). Note: an empirical probe showed the previously-claimed failure does not reproduce under ordered journal replay because intent/disposition records freeze the session record they were appended from; the neutralization is applied as the defensive fix the memo requires and is now pinned by tests. Hardening: - F3: peer-uid verification (SO_PEERCRED / LOCAL_PEERCRED) on observation socket accepts, configurable expected uid, rejecting unexpected peers before any frame is read. - F4: the replay cache is a bounded LRU (configurable capacity), so the old fixed 1024 cap can no longer permanently kill the channel; the durable registry remains the authoritative idempotency layer. - F5: transient wait/accept errors (EINTR/EAGAIN/ECONNABORTED/EMFILE/ ENFILE/ENOBUFS/ENOMEM) are retried inside serve_one_for with a bounded backoff ladder; only persistent or fatal errors surface. The Apple worker loop absorbs persistent failures up to a consecutive-failure bound and emits an audit event (new audit action "observation") on repeated failures instead of dying on the first hiccup. - F6: env/mount names generalized to GLOVE_GUEST_OBSERVATION_SOCKET / GLOVE_GUEST_OBSERVATION_TOKEN_FILE and the in-container token path to /home/agent/.glove-observation-token (Apple runtime, docs). Apple boundary: the soft lifecycle_operational() definition is unchanged; the lifecycle-disabled lane now additionally asserts start_session/ stop_session deny with method_not_found and leave the registry untouched (denial before side effects; the observation service only constructs inside a lifecycle-gated start). Docs: architecture.md and session-policy.md describe the guest specialized-channel admission model and the peer-uid/env naming. Validation (umask 0022): - cmake --preset dev/asan/tsan + build + ctest: 100% (76/76) in each configuration; asan/tsan are warnings-as-errors builds. - git diff --check clean; changed files clang-formatted with Homebrew llvm@22 against the repo .clang-format. - No fuzz target exists for the queue/registry (fuzz/ covers plans, bundles, manifests, MCP codec, jsonpath only), so no fuzz corpus run. - control_remote_validation_executor is a pre-existing load-sensitive sleep-race test; it flakes identically on the unmodified pr23-head baseline under CPU load (verified 1/6 both trees) and always passes isolated. Files: 23 changed (+~1500/-~360), including new guest_channel.{hpp,cpp}. Signed-off-by: 0xkite <me@hack4kites.dev>
1090 lines
43 KiB
C++
1090 lines
43 KiB
C++
#include "glove/container/digest.hpp"
|
|
#include "glove/container/receipt_producer.hpp"
|
|
#include "glove/control/guest_channel.hpp"
|
|
#include "glove/control/observation_intent_unix_server.hpp"
|
|
#include "glove/control/session_registry.hpp"
|
|
#include "glove/supervisor/library_bundle.hpp"
|
|
#include "glove/supervisor/path_alias.hpp"
|
|
#include "glove/supervisor/session_plan.hpp"
|
|
|
|
#include <arpa/inet.h>
|
|
#include <fcntl.h>
|
|
#include <glaze/glaze.hpp>
|
|
#include <sys/resource.h>
|
|
#include <sys/socket.h>
|
|
#include <sys/stat.h>
|
|
#include <sys/un.h>
|
|
#include <unistd.h>
|
|
|
|
#include <cerrno>
|
|
#include <chrono>
|
|
#include <cstdint>
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <optional>
|
|
#include <span>
|
|
#include <string>
|
|
#include <string_view>
|
|
#include <system_error>
|
|
#include <thread>
|
|
#include <utility>
|
|
#include <vector>
|
|
|
|
namespace wire_test {
|
|
|
|
struct enqueue_success {
|
|
std::uint8_t schema_version = 0;
|
|
std::string status;
|
|
std::uint64_t sequence = 0;
|
|
std::string intent_digest;
|
|
};
|
|
|
|
struct enqueue_error {
|
|
std::uint8_t schema_version = 0;
|
|
std::string code;
|
|
};
|
|
|
|
} // namespace wire_test
|
|
|
|
namespace {
|
|
|
|
#define REQUIRE(cond) \
|
|
do { \
|
|
if (!(cond)) { \
|
|
std::fprintf(stderr, "REQUIRE failed: %s @ %s:%d\n", #cond, __FILE__, __LINE__); \
|
|
return 1; \
|
|
} \
|
|
} while (0)
|
|
|
|
constexpr std::string_view audit_key =
|
|
"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
|
|
constexpr std::string_view controller_digest =
|
|
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc";
|
|
constexpr std::string_view channel_token =
|
|
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
|
constexpr std::string_view library_bundle =
|
|
R"({"schema_version":1,"source_library_ref":"bafy-test","source_manifest_digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","entries":[]})";
|
|
|
|
auto write_owner_only(const std::filesystem::path& path, std::string_view value) -> bool {
|
|
std::ofstream output{path, std::ios::binary | std::ios::trunc};
|
|
output << value;
|
|
output.flush();
|
|
return output.good() && ::chmod(path.c_str(), 0600) == 0;
|
|
}
|
|
|
|
class temporary_directory {
|
|
public:
|
|
temporary_directory() {
|
|
std::string pattern = "/tmp/glove-observation-server-test-XXXXXX";
|
|
if (char* created = ::mkdtemp(pattern.data()); created != nullptr) {
|
|
root_ = created;
|
|
}
|
|
}
|
|
|
|
temporary_directory(const temporary_directory&) = delete;
|
|
auto operator=(const temporary_directory&) -> temporary_directory& = delete;
|
|
|
|
temporary_directory(temporary_directory&& other) noexcept : root_{std::move(other.root_)} {}
|
|
|
|
auto operator=(temporary_directory&& other) noexcept -> temporary_directory& {
|
|
if (this != &other) {
|
|
std::error_code error;
|
|
std::filesystem::remove_all(root_, error);
|
|
root_ = std::move(other.root_);
|
|
}
|
|
return *this;
|
|
}
|
|
|
|
~temporary_directory() {
|
|
std::error_code error;
|
|
std::filesystem::remove_all(root_, error);
|
|
}
|
|
|
|
[[nodiscard]] auto root() const -> const std::filesystem::path& { return root_; }
|
|
|
|
private:
|
|
std::filesystem::path root_;
|
|
};
|
|
|
|
class unique_fd {
|
|
public:
|
|
explicit unique_fd(int value = -1) noexcept : value_{value} {}
|
|
|
|
unique_fd(const unique_fd&) = delete;
|
|
auto operator=(const unique_fd&) -> unique_fd& = delete;
|
|
|
|
unique_fd(unique_fd&& other) noexcept : value_{std::exchange(other.value_, -1)} {}
|
|
|
|
auto operator=(unique_fd&&) -> unique_fd& = delete;
|
|
|
|
~unique_fd() {
|
|
if (value_ >= 0) {
|
|
::close(value_);
|
|
}
|
|
}
|
|
|
|
[[nodiscard]] auto get() const noexcept -> int { return value_; }
|
|
|
|
private:
|
|
int value_;
|
|
};
|
|
|
|
auto library_bundle_digest() -> std::string {
|
|
const auto* bytes = reinterpret_cast<const unsigned char*>(library_bundle.data());
|
|
return glove::container::sha256_hex(std::span{bytes, library_bundle.size()}).value_or("");
|
|
}
|
|
|
|
auto launch_template() -> glove::supervisor::runtime_launch_template {
|
|
return {
|
|
.runtime_discovery = {},
|
|
.executable_path = "/usr/bin/true",
|
|
.executable_search_paths = {},
|
|
.arguments = {"--version"},
|
|
.environment = {"PATH=/usr/bin:/bin", "TERM=xterm-256color"},
|
|
.read_only_paths = {},
|
|
};
|
|
}
|
|
|
|
auto runtime_digest() -> std::string {
|
|
return glove::supervisor::runtime_launch_template_digest(launch_template()).value_or("");
|
|
}
|
|
|
|
auto valid_plan_at(std::uint64_t now_ms) -> std::string {
|
|
return R"({"schema_version":1,"runtime_id":"codex","runtime_template_id":"codex-safe","adapter_command_digest":")" +
|
|
runtime_digest() +
|
|
R"(","sandbox_backend":"linux_production","egress_policy_id":"no-network","tool_policy_id":"sage-readonly","path_grants":[{"alias":"workspace","access":"ephemeral_write","materialization":"copy","max_bytes":1048576,"ttl_secs":60,"cleanup_policy":"remove"}],"library_projections":[{"projection_id":"sage-core","content_digest":")" +
|
|
library_bundle_digest() +
|
|
R"(","destination_alias":"libraries"}],"secret_handles":["codex-token"],"limits":{"cpu_time_ms":1000,"memory_bytes":67108864,"pids":16,"wall_time_ms":2000,"disk_bytes":2097152,"terminal_output_bytes":1048576},"policy_revision":7,"expires_at_ms":)" +
|
|
std::to_string(now_ms + 120'000) + "}";
|
|
}
|
|
|
|
auto sage_guest_plan_at(std::uint64_t now_ms) -> std::string {
|
|
auto plan = valid_plan_at(now_ms);
|
|
for (const auto& [from, to] : {
|
|
std::pair{
|
|
std::string_view{"\"runtime_id\":\"codex\""},
|
|
std::string_view{"\"runtime_id\":\"sage-guest\""}
|
|
},
|
|
std::pair{
|
|
std::string_view{"\"runtime_template_id\":\"codex-safe\""},
|
|
std::string_view{"\"runtime_template_id\":\"sage-guest-safe\""}
|
|
},
|
|
std::pair{
|
|
std::string_view{"\"secret_handles\":[\"codex-token\"]"},
|
|
std::string_view{"\"secret_handles\":[]"}
|
|
},
|
|
}) {
|
|
const auto offset = plan.find(from);
|
|
if (offset != std::string::npos) {
|
|
plan.replace(offset, from.size(), to);
|
|
}
|
|
}
|
|
return plan;
|
|
}
|
|
|
|
// Registration example (test fixture): the host owns payload semantics.
|
|
auto test_channel_host() -> std::shared_ptr<glove::control::channel_host> {
|
|
auto host = std::make_shared<glove::control::channel_host>();
|
|
(void)host->register_channel({
|
|
.channel_id = "test-observation",
|
|
.schema_id = "sage.glove-observation.v1",
|
|
.body_validator = [](const glove::control::glove_observation_body&) { return true; },
|
|
.bounds = {
|
|
.max_items = 4'096,
|
|
.max_body_bytes = 8'192,
|
|
.max_ttl_ms = 600'000,
|
|
.max_skew_ms = 30'000,
|
|
},
|
|
});
|
|
return host;
|
|
}
|
|
|
|
auto validator_for(const std::filesystem::path& source)
|
|
-> glove::supervisor::result<glove::supervisor::session_plan_validator> {
|
|
using namespace glove::supervisor;
|
|
auto paths = path_alias_registry::build({
|
|
path_alias_policy{
|
|
.alias = "workspace",
|
|
.host_path = std::filesystem::canonical(source).string(),
|
|
.target_path = "/workspace",
|
|
.max_ttl_secs = 120,
|
|
.access = {
|
|
path_access_policy{
|
|
.access = path_access::ephemeral_write,
|
|
.materialization = path_materialization::copy,
|
|
.create_policy = path_create_policy::empty_directory,
|
|
.cleanup_policy = path_cleanup_policy::remove,
|
|
.max_bytes = 2'097'152,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
if (!paths) {
|
|
return std::unexpected(paths.error());
|
|
}
|
|
return session_plan_validator::build(
|
|
session_plan_policy{
|
|
.revision = 7,
|
|
.max_plan_ttl_ms = 120'000,
|
|
.runtime_templates =
|
|
{
|
|
runtime_template_policy{
|
|
.runtime_template_id = "codex-safe",
|
|
.runtime_id = "codex",
|
|
.adapter_command_digest = runtime_digest(),
|
|
.backend = sandbox_backend::linux_production,
|
|
.allowed_path_aliases = {"workspace"},
|
|
.allowed_projection_destinations = {"libraries"},
|
|
.launch = launch_template(),
|
|
.adoption = std::nullopt,
|
|
},
|
|
runtime_template_policy{
|
|
.runtime_template_id = "sage-guest-safe",
|
|
.runtime_id = "sage-guest",
|
|
.adapter_command_digest = runtime_digest(),
|
|
.backend = sandbox_backend::linux_production,
|
|
.allowed_path_aliases = {"workspace"},
|
|
.allowed_projection_destinations = {"libraries"},
|
|
.launch = launch_template(),
|
|
.adoption = std::nullopt,
|
|
},
|
|
},
|
|
.library_projection_destinations =
|
|
{
|
|
library_projection_destination_policy{
|
|
.alias = "libraries",
|
|
.target_path = "/opt/sage/library-bundles",
|
|
},
|
|
},
|
|
.resource_profiles =
|
|
{
|
|
resource_limits{
|
|
.cpu_time_ms = 1'000,
|
|
.memory_bytes = 67'108'864,
|
|
.pids = 16,
|
|
.wall_time_ms = 2'000,
|
|
.disk_bytes = 2'097'152,
|
|
.terminal_output_bytes = 1'048'576,
|
|
},
|
|
},
|
|
.egress_policy_ids = {"no-network"},
|
|
.tool_policy_ids = {"sage-readonly"},
|
|
.secret_handles = {"codex-token"},
|
|
.egress_policies = {},
|
|
.secret_mounts = {},
|
|
},
|
|
std::move(*paths)
|
|
);
|
|
}
|
|
|
|
auto cgroup_identity(std::uint32_t pid) -> glove::control::linux_cgroup_recovery_identity {
|
|
return {
|
|
.schema_version = 1,
|
|
.device = 42,
|
|
.inode = 20'000U + pid,
|
|
};
|
|
}
|
|
|
|
auto filesystem_identity() -> glove::control::linux_filesystem_recovery_identity {
|
|
return {
|
|
.schema_version = 1,
|
|
.disk_limit_bytes = 2'097'152,
|
|
.partitions = {{.alias = "workspace", .quota_bytes = 1'048'576}},
|
|
};
|
|
}
|
|
|
|
auto process_identity(std::uint32_t pid) -> glove::control::linux_process_identity {
|
|
return {
|
|
.schema_version = 1,
|
|
.pid = pid,
|
|
.boot_id = "12345678-1234-1234-1234-123456789abc",
|
|
.start_time_ticks = 10'000U + pid,
|
|
.cgroup_device = 42,
|
|
.cgroup_inode = 20'000U + pid,
|
|
.cgroup_path_digest = std::string(64, 'd'),
|
|
};
|
|
}
|
|
|
|
struct running_session_fixture {
|
|
temporary_directory temp;
|
|
std::shared_ptr<glove::control::session_registry> registry;
|
|
std::shared_ptr<glove::container::receipt_audit_producer> producer;
|
|
std::string session_id;
|
|
std::string profile_digest;
|
|
std::string projection_digest;
|
|
std::uint64_t start_ms = 0;
|
|
|
|
running_session_fixture(const running_session_fixture&) = delete;
|
|
auto operator=(const running_session_fixture&) -> running_session_fixture& = delete;
|
|
running_session_fixture(running_session_fixture&&) = default;
|
|
auto operator=(running_session_fixture&&) -> running_session_fixture& = default;
|
|
running_session_fixture() = default;
|
|
};
|
|
|
|
auto open_running_session(std::uint64_t start_ms) -> std::optional<running_session_fixture> {
|
|
running_session_fixture fixture;
|
|
fixture.start_ms = start_ms;
|
|
if (fixture.temp.root().empty()) {
|
|
return std::nullopt;
|
|
}
|
|
const auto source = fixture.temp.root() / "source";
|
|
if (!std::filesystem::create_directory(source)) {
|
|
return std::nullopt;
|
|
}
|
|
auto validator = validator_for(source);
|
|
if (!validator) {
|
|
return std::nullopt;
|
|
}
|
|
auto shared_validator =
|
|
std::make_shared<const glove::supervisor::session_plan_validator>(std::move(*validator));
|
|
|
|
const auto bundle_root = fixture.temp.root() / "library-bundles";
|
|
if (!std::filesystem::create_directory(bundle_root)) {
|
|
return std::nullopt;
|
|
}
|
|
if (::chmod(bundle_root.c_str(), 0700) != 0) {
|
|
return std::nullopt;
|
|
}
|
|
const auto bundle_path = bundle_root / (library_bundle_digest() + ".json");
|
|
{
|
|
std::ofstream output{bundle_path, std::ios::binary};
|
|
output.write(library_bundle.data(), static_cast<std::streamsize>(library_bundle.size()));
|
|
}
|
|
if (::chmod(bundle_path.c_str(), 0600) != 0) {
|
|
return std::nullopt;
|
|
}
|
|
auto opened_bundle_store = glove::supervisor::library_bundle_store::open(bundle_root);
|
|
if (!opened_bundle_store) {
|
|
return std::nullopt;
|
|
}
|
|
auto shared_bundle_store = std::make_shared<const glove::supervisor::library_bundle_store>(
|
|
std::move(*opened_bundle_store)
|
|
);
|
|
|
|
const auto audit_key_path = fixture.temp.root() / "receipt.key";
|
|
{
|
|
std::ofstream output{audit_key_path, std::ios::binary | std::ios::trunc};
|
|
output << audit_key << '\n';
|
|
}
|
|
if (::chmod(audit_key_path.c_str(), 0600) != 0) {
|
|
return std::nullopt;
|
|
}
|
|
auto producer = glove::container::receipt_audit_producer::initialize({
|
|
.key_path = audit_key_path,
|
|
.journal_path = fixture.temp.root() / "receipts.journal",
|
|
});
|
|
if (!producer) {
|
|
return std::nullopt;
|
|
}
|
|
if (!(*producer)->acknowledge_bootstrap((*producer)->anchor()).has_value()) {
|
|
return std::nullopt;
|
|
}
|
|
fixture.producer = *producer;
|
|
|
|
auto registry = glove::control::session_registry::open_or_create(
|
|
fixture.temp.root() / "sessions.journal",
|
|
shared_validator,
|
|
shared_bundle_store,
|
|
glove::control::default_session_registry_bytes,
|
|
test_channel_host()
|
|
);
|
|
if (!registry) {
|
|
return std::nullopt;
|
|
}
|
|
fixture.registry = std::move(*registry);
|
|
fixture.session_id = "observation-session";
|
|
fixture.profile_digest = std::string(64, 'a');
|
|
fixture.projection_digest = std::string(64, 'c');
|
|
|
|
auto created = fixture.registry->create(
|
|
fixture.session_id,
|
|
controller_digest,
|
|
sage_guest_plan_at(start_ms),
|
|
"create-observation-session",
|
|
start_ms
|
|
);
|
|
if (!created) {
|
|
return std::nullopt;
|
|
}
|
|
const glove::control::session_start_authorization authorization{
|
|
.schema_version = 1,
|
|
.authorization_id = "approval-observation-session",
|
|
.session_id = fixture.session_id,
|
|
.controller_plan_digest = std::string{controller_digest},
|
|
.plan_content_digest = created->plan_content_digest,
|
|
.approved_at_ms = start_ms + 1U,
|
|
.expires_at_ms = start_ms + 60'000U,
|
|
};
|
|
auto reserved = fixture.registry->reserve_start(
|
|
authorization, "reserve-observation-session", start_ms + 2U
|
|
);
|
|
if (!reserved) {
|
|
return std::nullopt;
|
|
}
|
|
const glove::control::session_execution_binding binding{
|
|
.schema_version = 1,
|
|
.session_id = fixture.session_id,
|
|
.controller_plan_digest = std::string{controller_digest},
|
|
.plan_content_digest = created->plan_content_digest,
|
|
.authorization_id = authorization.authorization_id,
|
|
.profile_digest = fixture.profile_digest,
|
|
.cgroup_identity = cgroup_identity(7001),
|
|
.filesystem_identity = filesystem_identity(),
|
|
};
|
|
auto reservation = fixture.producer->reserve_terminal(
|
|
binding.session_id, binding.controller_plan_digest, binding.profile_digest
|
|
);
|
|
if (!reservation) {
|
|
return std::nullopt;
|
|
}
|
|
auto starting = fixture.registry->mark_starting(
|
|
binding, *reservation, "starting-observation-session", start_ms + 3U
|
|
);
|
|
if (!starting) {
|
|
return std::nullopt;
|
|
}
|
|
const glove::control::session_running_commitment running{
|
|
.schema_version = 1,
|
|
.session_id = fixture.session_id,
|
|
.controller_plan_digest = binding.controller_plan_digest,
|
|
.plan_content_digest = binding.plan_content_digest,
|
|
.authorization_id = binding.authorization_id,
|
|
.profile_digest = fixture.profile_digest,
|
|
.process_identity = process_identity(7001),
|
|
.filesystem_identity = binding.filesystem_identity,
|
|
};
|
|
if (!fixture.registry
|
|
->mark_running(running, *reservation, "running-observation-session", start_ms + 4U)
|
|
.has_value()) {
|
|
return std::nullopt;
|
|
}
|
|
return std::optional<running_session_fixture>{std::move(fixture)};
|
|
}
|
|
|
|
auto make_enqueue_request(
|
|
std::string_view token,
|
|
std::string_view intent_id = "intent-1",
|
|
std::string_view observation = "guest-capability-inventory",
|
|
std::string_view value_digest =
|
|
"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
|
std::uint64_t item_count = 4,
|
|
std::optional<std::string_view> extra_field = std::nullopt
|
|
) -> std::string {
|
|
std::string request =
|
|
std::string{"{\"schema_version\":1,\"channel_token\":\""} + std::string{token} +
|
|
"\",\"body\":{\"schema\":\"sage.glove-observation.v1\",\"intent_id\":\"" +
|
|
std::string{intent_id} + "\",\"observation\":\"" + std::string{observation} +
|
|
"\",\"value_digest\":\"" + std::string{value_digest} +
|
|
"\",\"item_count\":" + std::to_string(item_count) + "}";
|
|
if (extra_field) {
|
|
request += ",\"" + std::string{*extra_field} + "\":1";
|
|
}
|
|
request += "}";
|
|
return request;
|
|
}
|
|
|
|
auto write_exact(int descriptor, const void* input, std::size_t size) -> bool {
|
|
const auto* bytes = static_cast<const std::byte*>(input);
|
|
std::size_t written = 0;
|
|
while (written < size) {
|
|
const auto result = ::write(descriptor, bytes + written, size - written);
|
|
if (result < 0 && errno == EINTR) {
|
|
continue;
|
|
}
|
|
if (result <= 0) {
|
|
return false;
|
|
}
|
|
written += static_cast<std::size_t>(result);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
auto read_exact(int descriptor, void* output, std::size_t size) -> bool {
|
|
auto* bytes = static_cast<std::byte*>(output);
|
|
std::size_t consumed = 0;
|
|
while (consumed < size) {
|
|
const auto result = ::read(descriptor, bytes + consumed, size - consumed);
|
|
if (result < 0 && errno == EINTR) {
|
|
continue;
|
|
}
|
|
if (result <= 0) {
|
|
return false;
|
|
}
|
|
consumed += static_cast<std::size_t>(result);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
auto connect_to(const std::filesystem::path& socket_path) -> unique_fd {
|
|
#if defined(__linux__)
|
|
unique_fd descriptor{::socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0)};
|
|
#else
|
|
unique_fd descriptor{::socket(AF_UNIX, SOCK_STREAM, 0)};
|
|
if (descriptor.get() >= 0) {
|
|
(void)::fcntl(descriptor.get(), F_SETFD, FD_CLOEXEC);
|
|
}
|
|
#endif
|
|
if (descriptor.get() < 0) {
|
|
return unique_fd{};
|
|
}
|
|
#if defined(SO_NOSIGPIPE)
|
|
const int enabled = 1;
|
|
if (::setsockopt(descriptor.get(), SOL_SOCKET, SO_NOSIGPIPE, &enabled, sizeof(enabled)) != 0) {
|
|
return unique_fd{};
|
|
}
|
|
#endif
|
|
::sockaddr_un address{};
|
|
address.sun_family = AF_UNIX;
|
|
const auto value = socket_path.string();
|
|
if (value.size() >= sizeof(address.sun_path)) {
|
|
return unique_fd{};
|
|
}
|
|
std::memcpy(address.sun_path, value.c_str(), value.size() + 1U);
|
|
if (::connect(
|
|
descriptor.get(), reinterpret_cast<const ::sockaddr*>(&address), sizeof(address)
|
|
) != 0) {
|
|
return unique_fd{};
|
|
}
|
|
return descriptor;
|
|
}
|
|
|
|
auto transact(const std::filesystem::path& socket_path, std::string_view frame)
|
|
-> std::optional<std::string> {
|
|
auto descriptor = connect_to(socket_path);
|
|
if (descriptor.get() < 0) {
|
|
return std::nullopt;
|
|
}
|
|
const auto size = htonl(static_cast<std::uint32_t>(frame.size()));
|
|
if (!write_exact(descriptor.get(), &size, sizeof(size))) {
|
|
return std::nullopt;
|
|
}
|
|
// An oversized-frame rejection may arrive before the peer drains the
|
|
// caller's body. Preserve the response even when that final write closes.
|
|
static_cast<void>(write_exact(descriptor.get(), frame.data(), frame.size()));
|
|
std::uint32_t response_size = 0;
|
|
if (!read_exact(descriptor.get(), &response_size, sizeof(response_size))) {
|
|
return std::nullopt;
|
|
}
|
|
const auto decoded_size = ntohl(response_size);
|
|
if (decoded_size == 0 || decoded_size > glove::control::max_observation_frame_bytes) {
|
|
return std::nullopt;
|
|
}
|
|
std::string response(decoded_size, '\0');
|
|
if (!read_exact(descriptor.get(), response.data(), response.size())) {
|
|
return std::nullopt;
|
|
}
|
|
return response;
|
|
}
|
|
|
|
auto decode_success(std::string_view frame) -> std::optional<wire_test::enqueue_success> {
|
|
wire_test::enqueue_success value{};
|
|
if (glz::read<glz::opts{.error_on_unknown_keys = true}>(value, frame)) {
|
|
return std::nullopt;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
auto decode_error(std::string_view frame) -> std::optional<wire_test::enqueue_error> {
|
|
wire_test::enqueue_error value{};
|
|
if (glz::read<glz::opts{.error_on_unknown_keys = true}>(value, frame)) {
|
|
return std::nullopt;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
auto response_is_redacted(std::string_view frame) -> bool {
|
|
return frame.find(channel_token) == std::string_view::npos &&
|
|
frame.find("\"channel_token\"") == std::string_view::npos &&
|
|
frame.find("observation-session") == std::string_view::npos &&
|
|
frame.find("secret_handles") == std::string_view::npos &&
|
|
frame.find("path_grants") == std::string_view::npos &&
|
|
frame.find("runtime_template_id") == std::string_view::npos &&
|
|
frame.find("intent-source") == std::string_view::npos;
|
|
}
|
|
|
|
auto server_config_for(
|
|
const running_session_fixture& fixture,
|
|
const std::filesystem::path& socket_path,
|
|
std::uint64_t session_expires_at_ms,
|
|
std::uint64_t io_timeout_ms = 100
|
|
) -> glove::control::observation_intent_unix_server_config {
|
|
return {
|
|
.socket_path = socket_path,
|
|
.sessions = fixture.registry.get(),
|
|
.session_id = fixture.session_id,
|
|
.runtime_id = "sage-guest",
|
|
.controller_plan_digest = std::string{controller_digest},
|
|
.profile_digest = fixture.profile_digest,
|
|
.projection_digest = fixture.projection_digest,
|
|
.policy_revision = 7,
|
|
.service_channel_id = "observation-session-observation-v1",
|
|
.channel_generation = 1,
|
|
.session_expires_at_ms = session_expires_at_ms,
|
|
.channel_token = std::string{channel_token},
|
|
.io_timeout_ms = io_timeout_ms,
|
|
.expected_peer_uid = ::geteuid(),
|
|
};
|
|
}
|
|
|
|
auto serve_in_background(
|
|
glove::control::observation_intent_unix_server& server,
|
|
std::size_t count,
|
|
std::optional<std::string>& error_out
|
|
) -> std::thread {
|
|
return std::thread{[&server, count, &error_out] {
|
|
for (std::size_t index = 0; index < count; ++index) {
|
|
auto served = server.serve_one_for(5'000);
|
|
if (!served) {
|
|
std::fprintf(stderr, "observation server failed: %s\n", served.error().c_str());
|
|
error_out = served.error();
|
|
return;
|
|
}
|
|
if (!*served) {
|
|
error_out = "accept deadline elapsed";
|
|
return;
|
|
}
|
|
}
|
|
}};
|
|
}
|
|
|
|
auto run() -> int {
|
|
// F5: transient accept-boundary errors are retryable; fatal config and
|
|
// descriptor-state errors are not.
|
|
REQUIRE(glove::control::observation_transient_accept_error(EINTR));
|
|
REQUIRE(glove::control::observation_transient_accept_error(EAGAIN));
|
|
REQUIRE(glove::control::observation_transient_accept_error(ECONNABORTED));
|
|
REQUIRE(glove::control::observation_transient_accept_error(EMFILE));
|
|
REQUIRE(glove::control::observation_transient_accept_error(ENFILE));
|
|
REQUIRE(glove::control::observation_transient_accept_error(ENOBUFS));
|
|
REQUIRE(glove::control::observation_transient_accept_error(ENOMEM));
|
|
REQUIRE(!glove::control::observation_transient_accept_error(EBADF));
|
|
REQUIRE(!glove::control::observation_transient_accept_error(EINVAL));
|
|
REQUIRE(!glove::control::observation_transient_accept_error(EACCES));
|
|
|
|
const auto now_ms = []() -> std::uint64_t {
|
|
using namespace std::chrono;
|
|
const auto ms = duration_cast<milliseconds>(system_clock::now().time_since_epoch());
|
|
return ms.count() < 0 ? 0U : static_cast<std::uint64_t>(ms.count());
|
|
}();
|
|
const std::uint64_t start_ms = now_ms > 10'000U ? now_ms - 10'000U : 1U;
|
|
|
|
auto fixture = open_running_session(start_ms);
|
|
REQUIRE(fixture.has_value());
|
|
const auto socket_path = fixture->temp.root() / "observation.sock";
|
|
|
|
auto config = server_config_for(*fixture, socket_path, start_ms + 120'000U);
|
|
auto server = glove::control::observation_intent_unix_server::create(config);
|
|
REQUIRE(server.has_value());
|
|
REQUIRE((*server)->socket_path() == socket_path);
|
|
|
|
struct stat socket_status{};
|
|
REQUIRE(::lstat(socket_path.c_str(), &socket_status) == 0);
|
|
REQUIRE(S_ISSOCK(socket_status.st_mode));
|
|
REQUIRE(socket_status.st_uid == ::geteuid());
|
|
REQUIRE((socket_status.st_mode & 0777U) == 0600U);
|
|
|
|
std::optional<std::string> serve_error;
|
|
auto server_thread = serve_in_background(**server, 1, serve_error);
|
|
|
|
const auto request = make_enqueue_request(channel_token);
|
|
auto response = transact(socket_path, request);
|
|
server_thread.join();
|
|
REQUIRE(!serve_error.has_value());
|
|
REQUIRE(response.has_value());
|
|
REQUIRE(response_is_redacted(*response));
|
|
auto success = decode_success(*response);
|
|
REQUIRE(success.has_value());
|
|
REQUIRE(success->schema_version == 1);
|
|
REQUIRE(success->status == "queued");
|
|
REQUIRE(success->sequence != 0);
|
|
REQUIRE(success->intent_digest.size() == 64U);
|
|
const auto count_after_enqueue = fixture->registry->record_count();
|
|
|
|
serve_error.reset();
|
|
server_thread = serve_in_background(**server, 1, serve_error);
|
|
auto replay_response = transact(socket_path, request);
|
|
server_thread.join();
|
|
REQUIRE(!serve_error.has_value());
|
|
REQUIRE(replay_response.has_value());
|
|
auto replay_success = decode_success(*replay_response);
|
|
REQUIRE(replay_success.has_value());
|
|
REQUIRE(replay_success->sequence == success->sequence);
|
|
REQUIRE(replay_success->intent_digest == success->intent_digest);
|
|
REQUIRE(fixture->registry->record_count() == count_after_enqueue);
|
|
|
|
serve_error.reset();
|
|
server_thread = serve_in_background(**server, 1, serve_error);
|
|
auto conflict_response = transact(
|
|
socket_path,
|
|
make_enqueue_request(
|
|
channel_token, "intent-1", "guest-capability-inventory", std::string(64, 'e')
|
|
)
|
|
);
|
|
server_thread.join();
|
|
REQUIRE(!serve_error.has_value());
|
|
REQUIRE(conflict_response.has_value());
|
|
auto conflict_error = decode_error(*conflict_response);
|
|
REQUIRE(conflict_error.has_value());
|
|
REQUIRE(conflict_error->code == "idempotency_conflict");
|
|
REQUIRE(response_is_redacted(*conflict_response));
|
|
REQUIRE(fixture->registry->record_count() == count_after_enqueue);
|
|
|
|
const auto count_before_bad_token = fixture->registry->record_count();
|
|
serve_error.reset();
|
|
server_thread = serve_in_background(**server, 1, serve_error);
|
|
auto bad_token_response =
|
|
transact(socket_path, make_enqueue_request(std::string(64, 'f'), "intent-2"));
|
|
server_thread.join();
|
|
REQUIRE(!serve_error.has_value());
|
|
REQUIRE(bad_token_response.has_value());
|
|
auto bad_token_error = decode_error(*bad_token_response);
|
|
REQUIRE(bad_token_error.has_value());
|
|
REQUIRE(bad_token_error->code == "unauthorized");
|
|
REQUIRE(fixture->registry->record_count() == count_before_bad_token);
|
|
|
|
serve_error.reset();
|
|
server_thread = serve_in_background(**server, 1, serve_error);
|
|
auto unknown_field_response = transact(
|
|
socket_path,
|
|
make_enqueue_request(
|
|
channel_token,
|
|
"intent-3",
|
|
"guest-capability-inventory",
|
|
std::string(64, 'b'),
|
|
4,
|
|
"issued_at_ms"
|
|
)
|
|
);
|
|
server_thread.join();
|
|
REQUIRE(!serve_error.has_value());
|
|
REQUIRE(unknown_field_response.has_value());
|
|
auto unknown_field_error = decode_error(*unknown_field_response);
|
|
REQUIRE(unknown_field_error.has_value());
|
|
REQUIRE(unknown_field_error->code == "invalid_request");
|
|
|
|
serve_error.reset();
|
|
server_thread = serve_in_background(**server, 1, serve_error);
|
|
auto missing_body_response = transact(
|
|
socket_path,
|
|
std::string{"{\"schema_version\":1,\"channel_token\":\""} + std::string{channel_token} +
|
|
"\"}"
|
|
);
|
|
server_thread.join();
|
|
REQUIRE(!serve_error.has_value());
|
|
REQUIRE(missing_body_response.has_value());
|
|
auto missing_body_error = decode_error(*missing_body_response);
|
|
REQUIRE(missing_body_error.has_value());
|
|
REQUIRE(missing_body_error->code == "invalid_request");
|
|
|
|
serve_error.reset();
|
|
server_thread = serve_in_background(**server, 1, serve_error);
|
|
const std::string oversized(glove::control::max_observation_frame_bytes + 1U, 'x');
|
|
auto oversized_response = transact(
|
|
socket_path,
|
|
std::string{"{\"schema_version\":1,\"channel_token\":\""} + std::string{channel_token} +
|
|
"\",\"body\":{\"schema\":\"sage.glove-observation.v1\",\"intent_id\":\"intent-4\","
|
|
"\"observation\":\"" +
|
|
oversized + "\",\"value_digest\":\"" + std::string(64, 'c') + "\",\"item_count\":1}}"
|
|
);
|
|
server_thread.join();
|
|
REQUIRE(!serve_error.has_value());
|
|
REQUIRE(oversized_response.has_value());
|
|
auto oversized_error = decode_error(*oversized_response);
|
|
REQUIRE(oversized_error.has_value());
|
|
REQUIRE(oversized_error->code == "invalid_request");
|
|
|
|
const auto count_before_resilience = fixture->registry->record_count();
|
|
serve_error.reset();
|
|
server_thread = serve_in_background(**server, 1, serve_error);
|
|
{
|
|
auto truncated_descriptor = connect_to(socket_path);
|
|
REQUIRE(truncated_descriptor.get() >= 0);
|
|
const std::uint32_t declared = htonl(128U);
|
|
REQUIRE(write_exact(truncated_descriptor.get(), &declared, sizeof(declared)));
|
|
REQUIRE(write_exact(truncated_descriptor.get(), "{\"schema_version\":1", 18U));
|
|
}
|
|
server_thread.join();
|
|
REQUIRE(!serve_error.has_value());
|
|
|
|
serve_error.reset();
|
|
server_thread = serve_in_background(**server, 1, serve_error);
|
|
auto resilience_after_truncation = transact(
|
|
socket_path,
|
|
make_enqueue_request(
|
|
channel_token,
|
|
"intent-resilience-truncated",
|
|
"guest-capability-inventory",
|
|
std::string(64, '7')
|
|
)
|
|
);
|
|
server_thread.join();
|
|
REQUIRE(!serve_error.has_value());
|
|
REQUIRE(resilience_after_truncation.has_value());
|
|
auto resilience_truncated_success = decode_success(*resilience_after_truncation);
|
|
REQUIRE(resilience_truncated_success.has_value());
|
|
REQUIRE(resilience_truncated_success->status == "queued");
|
|
REQUIRE(fixture->registry->record_count() > count_before_resilience);
|
|
|
|
serve_error.reset();
|
|
server_thread = serve_in_background(**server, 1, serve_error);
|
|
{
|
|
auto stalled = connect_to(socket_path);
|
|
REQUIRE(stalled.get() >= 0);
|
|
}
|
|
server_thread.join();
|
|
REQUIRE(!serve_error.has_value());
|
|
|
|
serve_error.reset();
|
|
server_thread = serve_in_background(**server, 1, serve_error);
|
|
auto resilience_after_disconnect = transact(
|
|
socket_path,
|
|
make_enqueue_request(
|
|
channel_token,
|
|
"intent-resilience-disconnect",
|
|
"guest-capability-inventory",
|
|
std::string(64, '6')
|
|
)
|
|
);
|
|
server_thread.join();
|
|
REQUIRE(!serve_error.has_value());
|
|
REQUIRE(resilience_after_disconnect.has_value());
|
|
auto resilience_disconnect_success = decode_success(*resilience_after_disconnect);
|
|
REQUIRE(resilience_disconnect_success.has_value());
|
|
REQUIRE(resilience_disconnect_success->status == "queued");
|
|
REQUIRE(fixture->registry->record_count() > count_before_resilience + 1U);
|
|
|
|
server->reset();
|
|
REQUIRE(::access(socket_path.c_str(), F_OK) != 0);
|
|
|
|
REQUIRE(::chmod(fixture->temp.root().c_str(), 0755) == 0);
|
|
REQUIRE(
|
|
!glove::control::observation_intent_unix_server::create(
|
|
server_config_for(*fixture, fixture->temp.root() / "unsafe.sock", start_ms + 120'000U)
|
|
)
|
|
.has_value()
|
|
);
|
|
REQUIRE(::chmod(fixture->temp.root().c_str(), 0700) == 0);
|
|
|
|
REQUIRE(write_owner_only(socket_path, "not-a-socket"));
|
|
REQUIRE(!glove::control::observation_intent_unix_server::create(
|
|
server_config_for(*fixture, socket_path, start_ms + 120'000U)
|
|
)
|
|
.has_value());
|
|
std::filesystem::remove(socket_path);
|
|
|
|
auto mismatched_config =
|
|
server_config_for(*fixture, fixture->temp.root() / "mismatch.sock", start_ms + 120'000U);
|
|
mismatched_config.profile_digest = std::string(64, 'f');
|
|
auto mismatch_server =
|
|
glove::control::observation_intent_unix_server::create(mismatched_config);
|
|
REQUIRE(mismatch_server.has_value());
|
|
const auto count_before_mismatch = fixture->registry->record_count();
|
|
serve_error.reset();
|
|
server_thread = serve_in_background(**mismatch_server, 1, serve_error);
|
|
auto mismatch_response = transact(
|
|
mismatch_server->get()->socket_path(),
|
|
make_enqueue_request(
|
|
channel_token, "intent-mismatch", "guest-capability-inventory", std::string(64, '9')
|
|
)
|
|
);
|
|
server_thread.join();
|
|
REQUIRE(!serve_error.has_value());
|
|
REQUIRE(mismatch_response.has_value());
|
|
auto mismatch_error = decode_error(*mismatch_response);
|
|
REQUIRE(mismatch_error.has_value());
|
|
REQUIRE(mismatch_error->code == "intent_rejected");
|
|
REQUIRE(fixture->registry->record_count() == count_before_mismatch);
|
|
|
|
auto expired_config =
|
|
server_config_for(*fixture, fixture->temp.root() / "expired.sock", start_ms - 1U);
|
|
auto expired_server = glove::control::observation_intent_unix_server::create(expired_config);
|
|
REQUIRE(expired_server.has_value());
|
|
serve_error.reset();
|
|
server_thread = serve_in_background(**expired_server, 1, serve_error);
|
|
auto expired_response = transact(
|
|
expired_server->get()->socket_path(),
|
|
make_enqueue_request(
|
|
channel_token, "intent-expired", "guest-capability-inventory", std::string(64, '8')
|
|
)
|
|
);
|
|
server_thread.join();
|
|
REQUIRE(!serve_error.has_value());
|
|
REQUIRE(expired_response.has_value());
|
|
auto expired_error = decode_error(*expired_response);
|
|
REQUIRE(expired_error.has_value());
|
|
REQUIRE(expired_error->code == "intent_expired");
|
|
|
|
// F3: peer-credential enforcement. A server configured with an expected
|
|
// uid that cannot match the (same-uid) test client rejects connections
|
|
// before any frame is read, and the registry stays untouched.
|
|
{
|
|
auto guarded_config =
|
|
server_config_for(*fixture, fixture->temp.root() / "guarded.sock", start_ms + 120'000U);
|
|
guarded_config.expected_peer_uid = ::geteuid() + 1U;
|
|
auto guarded_server =
|
|
glove::control::observation_intent_unix_server::create(guarded_config);
|
|
REQUIRE(guarded_server.has_value());
|
|
const auto count_before_guard = fixture->registry->record_count();
|
|
serve_error.reset();
|
|
server_thread = serve_in_background(**guarded_server, 1, serve_error);
|
|
auto guarded_response = transact(
|
|
guarded_server->get()->socket_path(),
|
|
make_enqueue_request(channel_token, "intent-guarded")
|
|
);
|
|
server_thread.join();
|
|
REQUIRE(!serve_error.has_value());
|
|
// The peer is rejected without a response frame.
|
|
REQUIRE(!guarded_response.has_value());
|
|
REQUIRE(fixture->registry->record_count() == count_before_guard);
|
|
}
|
|
|
|
// F4: replay-cache eviction (LRU) keeps the channel alive past the
|
|
// cache capacity instead of returning capacity_exhausted forever.
|
|
{
|
|
auto bounded_config =
|
|
server_config_for(*fixture, fixture->temp.root() / "bounded.sock", start_ms + 120'000U);
|
|
bounded_config.replay_cache_capacity = 2U;
|
|
auto bounded_server =
|
|
glove::control::observation_intent_unix_server::create(bounded_config);
|
|
REQUIRE(bounded_server.has_value());
|
|
const auto bounded_socket = bounded_server->get()->socket_path();
|
|
const auto count_before_lru = fixture->registry->record_count();
|
|
|
|
// One serve iteration per transact below.
|
|
serve_error.reset();
|
|
server_thread = serve_in_background(**bounded_server, 6, serve_error);
|
|
|
|
auto lru_first =
|
|
decode_success(*transact(bounded_socket, make_enqueue_request(channel_token, "lru-1")));
|
|
REQUIRE(lru_first.has_value());
|
|
auto lru_second =
|
|
decode_success(*transact(bounded_socket, make_enqueue_request(channel_token, "lru-2")));
|
|
REQUIRE(lru_second.has_value());
|
|
auto lru_third =
|
|
decode_success(*transact(bounded_socket, make_enqueue_request(channel_token, "lru-3")));
|
|
REQUIRE(lru_third.has_value());
|
|
|
|
// The cached tail still replays through the cache.
|
|
auto lru_cached_replay =
|
|
transact(bounded_socket, make_enqueue_request(channel_token, "lru-3"));
|
|
auto cached_replay = decode_success(*lru_cached_replay);
|
|
REQUIRE(cached_replay.has_value());
|
|
REQUIRE(cached_replay->sequence == lru_third->sequence);
|
|
|
|
// The evicted head replays through the durable registry: either an
|
|
// exact replay (same host-stamped millisecond) or a drifted
|
|
// idempotency_conflict — never capacity_exhausted, and the channel
|
|
// stays live.
|
|
auto lru_evicted_replay =
|
|
transact(bounded_socket, make_enqueue_request(channel_token, "lru-1"));
|
|
auto evicted_success = decode_success(*lru_evicted_replay);
|
|
auto evicted_error = decode_error(*lru_evicted_replay);
|
|
REQUIRE(
|
|
evicted_success.has_value() ||
|
|
(evicted_error.has_value() && evicted_error->code == "idempotency_conflict")
|
|
);
|
|
|
|
// New intents keep flowing past the old 1024-cap behavior.
|
|
auto lru_fourth = transact(bounded_socket, make_enqueue_request(channel_token, "lru-4"));
|
|
REQUIRE(decode_success(*lru_fourth).has_value());
|
|
server_thread.join();
|
|
REQUIRE(!serve_error.has_value());
|
|
REQUIRE(fixture->registry->record_count() == count_before_lru + 4U);
|
|
}
|
|
|
|
// F5: a transient accept failure (real fd exhaustion) is retried with a
|
|
// bounded backoff and must not permanently kill the channel.
|
|
{
|
|
auto retry_config =
|
|
server_config_for(*fixture, fixture->temp.root() / "retry.sock", start_ms + 120'000U);
|
|
auto retry_server = glove::control::observation_intent_unix_server::create(retry_config);
|
|
REQUIRE(retry_server.has_value());
|
|
const auto retry_socket = retry_server->get()->socket_path();
|
|
|
|
auto held = connect_to(retry_socket);
|
|
REQUIRE(held.get() >= 0);
|
|
const auto held_request = make_enqueue_request(channel_token, "intent-retry");
|
|
const auto held_size = htonl(static_cast<std::uint32_t>(held_request.size()));
|
|
REQUIRE(write_exact(held.get(), &held_size, sizeof(held_size)));
|
|
REQUIRE(write_exact(held.get(), held_request.data(), held_request.size()));
|
|
|
|
// Genuinely exhaust descriptors so the next accept() fails with
|
|
// EMFILE where the platform enforces it at allocation time.
|
|
std::vector<int> exhausted;
|
|
bool exhausted_descriptors = false;
|
|
for (;;) {
|
|
const int descriptor = ::dup(0);
|
|
if (descriptor < 0) {
|
|
exhausted_descriptors = errno == EMFILE;
|
|
break;
|
|
}
|
|
exhausted.push_back(descriptor);
|
|
if (exhausted.size() > 4096U) {
|
|
break;
|
|
}
|
|
}
|
|
if (exhausted_descriptors) {
|
|
REQUIRE(!(*retry_server)->serve_one_for(2'000).has_value());
|
|
for (const int descriptor : exhausted) {
|
|
::close(descriptor);
|
|
}
|
|
// The channel recovers: both the queued connection and the new
|
|
// recovery request are accepted and processed.
|
|
const auto count_before_retry = fixture->registry->record_count();
|
|
serve_error.reset();
|
|
server_thread = serve_in_background(**retry_server, 2, serve_error);
|
|
auto retry_response = transact(
|
|
retry_socket, make_enqueue_request(channel_token, "intent-retry-recovered")
|
|
);
|
|
server_thread.join();
|
|
REQUIRE(!serve_error.has_value());
|
|
REQUIRE(retry_response.has_value());
|
|
REQUIRE(decode_success(*retry_response).has_value());
|
|
REQUIRE(fixture->registry->record_count() == count_before_retry + 2U);
|
|
} else {
|
|
// macOS keeps new allocations below its high-water mark, so the
|
|
// accept may simply succeed; the channel must still stay healthy
|
|
// and process both the queued and a fresh request.
|
|
for (const int descriptor : exhausted) {
|
|
::close(descriptor);
|
|
}
|
|
const auto count_before_retry = fixture->registry->record_count();
|
|
serve_error.reset();
|
|
server_thread = serve_in_background(**retry_server, 2, serve_error);
|
|
auto retry_response = transact(
|
|
retry_socket, make_enqueue_request(channel_token, "intent-retry-recovered")
|
|
);
|
|
server_thread.join();
|
|
REQUIRE(!serve_error.has_value());
|
|
REQUIRE(retry_response.has_value());
|
|
REQUIRE(decode_success(*retry_response).has_value());
|
|
REQUIRE(fixture->registry->record_count() >= count_before_retry + 1U);
|
|
}
|
|
}
|
|
|
|
auto cleanup_config =
|
|
server_config_for(*fixture, fixture->temp.root() / "cleanup.sock", start_ms + 120'000U);
|
|
auto cleanup_server = glove::control::observation_intent_unix_server::create(cleanup_config);
|
|
REQUIRE(cleanup_server.has_value());
|
|
struct stat created_socket{};
|
|
REQUIRE(::lstat(cleanup_config.socket_path.c_str(), &created_socket) == 0);
|
|
const auto created_inode = created_socket.st_ino;
|
|
REQUIRE(::unlink(cleanup_config.socket_path.c_str()) == 0);
|
|
REQUIRE(write_owner_only(cleanup_config.socket_path, "replacement"));
|
|
struct stat replacement{};
|
|
REQUIRE(::lstat(cleanup_config.socket_path.c_str(), &replacement) == 0);
|
|
REQUIRE(replacement.st_ino != created_inode);
|
|
cleanup_server->reset();
|
|
REQUIRE(std::filesystem::exists(cleanup_config.socket_path));
|
|
REQUIRE(std::filesystem::file_size(cleanup_config.socket_path) > 0U);
|
|
|
|
return 0;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
auto main() -> int {
|
|
return run();
|
|
}
|