From 37e0d9b9ae468a1c33be1151b532b3a62e63ba57 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Sun, 1 Nov 2026 01:56:31 +0100 Subject: [PATCH] feat(discovery): find live instances across sandboxes; build info Each app writes $HOME/.cache/crafter-debug/instances/.json (0600, in a 0700 directory) and holds an flock on .lock while it lives. Every `flatpak run` is its own pid namespace, so pids collide and can neither name an instance nor tell whether it lives; a random id names it and the lock says it lives, since flock works across sandboxes on the shared filesystem. An app killed without its destructor leaves its files with the lock free, and the next listing removes them. The lock is created under a temporary name, locked, then renamed, as in Crafter.Review's LifetimeLock, so it is never seen unlocked while its app lives. Inside a Flatpak the record also carries the sandbox's instance id (for `flatpak enter`) and the host pid of its init (to end the whole sandbox from the host). project.cpp now generates :BuildInfo per core variant: a hash of the library's sources and the path of the client built with it. App and client share the core, so they report the same id exactly when built from the same sources; this is the safety net under building the client in the same pass as the app. Co-Authored-By: Claude Opus 5.5 --- .../Crafter.Debug.Core-Discovery.cpp | 171 ++++++++++++++++++ interfaces/Crafter.Debug.Core-BuildInfo.cppm | 17 ++ interfaces/Crafter.Debug.Core-Discovery.cppm | 72 ++++++++ interfaces/Crafter.Debug.Core.cppm | 2 + project.cpp | 102 +++++++++-- tests/ShouldDiscoverLiveInstances/main.cpp | 87 +++++++++ 6 files changed, 433 insertions(+), 18 deletions(-) create mode 100644 implementations/Crafter.Debug.Core-Discovery.cpp create mode 100644 interfaces/Crafter.Debug.Core-BuildInfo.cppm create mode 100644 interfaces/Crafter.Debug.Core-Discovery.cppm create mode 100644 tests/ShouldDiscoverLiveInstances/main.cpp diff --git a/implementations/Crafter.Debug.Core-Discovery.cpp b/implementations/Crafter.Debug.Core-Discovery.cpp new file mode 100644 index 0000000..8f66729 --- /dev/null +++ b/implementations/Crafter.Debug.Core-Discovery.cpp @@ -0,0 +1,171 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +module; +#include +#include +#include +#include +module Crafter.Debug.Core:Discovery_impl; +import std; +import :Value; +import :Codec; +import :Discovery; + +using namespace Crafter::Debug; +namespace fs = std::filesystem; + +namespace { + std::string RandomId() { + std::random_device random; + return std::format("{:08x}{:08x}", random(), random()); + } + + std::string ReadFile(const fs::path& path) { + std::ifstream in(path, std::ios::binary); + if (!in) return {}; + return std::string((std::istreambuf_iterator(in)), std::istreambuf_iterator()); + } + + // The value of `key` in `section` of an INI-style file such as /.flatpak-info. + std::string IniValue(std::string_view text, std::string_view section, std::string_view key) { + bool inSection = false; + for (const auto part : std::views::split(text, '\n')) { + const std::string_view line(std::ranges::data(part), std::ranges::size(part)); + if (line.starts_with('[')) { + inSection = line.size() == section.size() + 2 && line.substr(1, section.size()) == section && line.back() == ']'; + continue; + } + if (inSection && line.size() > key.size() && line.starts_with(key) && line[key.size()] == '=') return std::string(line.substr(key.size() + 1)); + } + return {}; + } + + // The host pid of the sandbox's init: child-pid in the bwrapinfo.json + // Flatpak keeps for the instance under $XDG_RUNTIME_DIR, or 0. + std::int64_t SandboxInitPid(std::string_view instance) { + if (instance.empty()) return 0; + const char* runtime = std::getenv("XDG_RUNTIME_DIR"); + const fs::path base = runtime != nullptr && *runtime != 0 ? fs::path(runtime) : fs::path(std::format("/run/user/{}", ::getuid())); + const std::string text = ReadFile(base / ".flatpak" / std::string(instance) / "bwrapinfo.json"); + if (text.empty()) return 0; + try { + const Value info = Value::Parse(text); + const Value* pid = info.Find("child-pid"); + return pid != nullptr && pid->IsInteger() ? pid->AsInteger() : 0; + } catch (const ParseError&) { + return 0; + } + } + + std::system_error Failure(std::string_view what) { + return std::system_error(errno, std::generic_category(), std::string(what)); + } + + // Through a temporary name and a rename, so a reader never sees half + // a file. Created with `mode`, before the umask. + void WriteAtomically(const fs::path& path, std::string_view text, std::uint32_t mode) { + fs::path temp = path; + temp += std::format(".{}.tmp", RandomId()); + const std::int32_t fd = ::open(temp.c_str(), O_CREAT | O_EXCL | O_WRONLY | O_CLOEXEC, mode); + if (fd < 0) throw Failure(std::format("creating {}", temp.string())); + std::size_t written = 0; + while (written < text.size()) { + const auto count = ::write(fd, text.data() + written, text.size() - written); + if (count < 0 && errno == EINTR) continue; + if (count <= 0) { + const std::system_error error = Failure(std::format("writing {}", temp.string())); + ::close(fd); + ::unlink(temp.c_str()); + throw error; + } + written += static_cast(count); + } + ::close(fd); + fs::rename(temp, path); + } +} + +fs::path Crafter::Debug::DiscoveryDirectory() { + if (const char* dir = std::getenv("CRAFTER_DEBUG_DIR"); dir != nullptr && *dir != 0) return fs::path(dir); + const char* home = std::getenv("HOME"); + return fs::path(home != nullptr && *home != 0 ? home : "/tmp") / ".cache" / "crafter-debug" / "instances"; +} + +Registration::Registration(InstanceRecord given) : record(std::move(given)) { + const fs::path dir = DiscoveryDirectory(); + fs::create_directories(dir); + std::error_code ignored; + fs::permissions(dir, fs::perms::owner_all, ignored); + + record.instanceId = RandomId(); + record.pid = ::getpid(); + record.exe = fs::read_symlink("/proc/self/exe", ignored).string(); + record.startedAt = std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()).count(); + const std::string info = ReadFile("/.flatpak-info"); + if (!info.empty()) { + record.flatpakInstance = IniValue(info, "Instance", "instance-id"); + record.hostInitPid = SandboxInitPid(record.flatpakInstance); + } + + // The lock first, made under a temporary name, locked, then renamed into + // place, so nothing ever finds it unlocked while this instance lives; + // then the record, which a lister only reads once the lock is there. + lockPath = dir / std::format("{}.lock", record.instanceId); + recordPath = dir / std::format("{}.json", record.instanceId); + fs::path tempLock = lockPath; + tempLock += ".tmp"; + lockFd = ::open(tempLock.c_str(), O_CREAT | O_EXCL | O_RDWR | O_CLOEXEC, 0600); + if (lockFd < 0) throw Failure(std::format("creating {}", tempLock.string())); + if (::flock(lockFd, LOCK_EX | LOCK_NB) != 0) { + const std::system_error error = Failure(std::format("locking {}", tempLock.string())); + ::close(lockFd); + ::unlink(tempLock.c_str()); + throw error; + } + fs::rename(tempLock, lockPath); + WriteAtomically(recordPath, Encode(record).Dump(), 0600); +} + +Registration::~Registration() { + std::error_code ignored; + fs::remove(recordPath, ignored); + fs::remove(lockPath, ignored); + if (lockFd >= 0) ::close(lockFd); +} + +std::vector Crafter::Debug::ListInstances() { + std::vector live; + std::error_code ec; + for (const fs::directory_entry& entry : fs::directory_iterator(DiscoveryDirectory(), ec)) { + const fs::path path = entry.path(); + if (path.extension() != ".json") continue; + fs::path lock = path; + lock.replace_extension(".lock"); + bool alive = false; + const std::int32_t fd = ::open(lock.c_str(), O_RDONLY | O_CLOEXEC); + if (fd >= 0) { + alive = ::flock(fd, LOCK_EX | LOCK_NB) != 0 && errno == EWOULDBLOCK; + ::close(fd); + } + if (!alive) { + std::error_code ignored; + fs::remove(path, ignored); + fs::remove(lock, ignored); + continue; + } + Value parsed; + try { + parsed = Value::Parse(ReadFile(path)); + } catch (const ParseError&) { + continue; + } + // A newer app may add fields; what this client knows still decodes. + InstanceRecord record; + DecodeContext context; + Decode(parsed, record, context); + live.push_back({ std::move(record), path }); + } + std::ranges::sort(live, [](const Instance& a, const Instance& b) { return a.record.startedAt > b.record.startedAt; }); + return live; +} diff --git a/interfaces/Crafter.Debug.Core-BuildInfo.cppm b/interfaces/Crafter.Debug.Core-BuildInfo.cppm new file mode 100644 index 0000000..68caa44 --- /dev/null +++ b/interfaces/Crafter.Debug.Core-BuildInfo.cppm @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// What the build knew: a hash of Crafter.Debug's own sources, and where the +// client built alongside this library is. project.cpp generates the +// definitions into build/generated/, so the app and the client, which share +// this core, report the same values exactly when they came from the same +// sources: the safety net under "the client is built with the app". +export module Crafter.Debug.Core:BuildInfo; +import std; + +export namespace Crafter::Debug::Detail { + // 16 hex digits over every interface and implementation source. + std::string_view SourceId(); + // The absolute path of the crafter-debug client this build compiled. + std::string_view ClientPath(); +} diff --git a/interfaces/Crafter.Debug.Core-Discovery.cppm b/interfaces/Crafter.Debug.Core-Discovery.cppm new file mode 100644 index 0000000..90a747f --- /dev/null +++ b/interfaces/Crafter.Debug.Core-Discovery.cppm @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// How crafter-debug finds running apps. Each app writes +// $HOME/.cache/crafter-debug/instances/.json, readable by its +// user only, and holds an flock on .lock for as long as it runs. +// +// Spelled with $HOME, not $XDG_CACHE_HOME: inside the CrafterBuild Flatpak +// XDG_CACHE_HOME points into ~/.var/app/..., which a native crafter-build +// would not see, while $HOME/.cache is the same path for both. Every +// `flatpak run` is its own sandbox with its own pid namespace, so pids +// collide and cannot name an instance or tell whether it lives: a random +// instance id names it, and the lock says whether it lives, since flock works +// across sandboxes on the shared filesystem. An app killed without running +// its destructor leaves its files behind with the lock free, and the next +// listing removes them. The pattern is Crafter.Review's LifetimeLock. +export module Crafter.Debug.Core:Discovery; +import std; + +export namespace Crafter::Debug { + struct InstanceRecord { + std::string name; + std::string instanceId; + // The app's own pid, in its own pid namespace: what a debugger + // started inside that sandbox attaches to. + std::int64_t pid = 0; + // Inside a Flatpak: the sandbox's [Instance] instance-id (for + // `flatpak enter`), and the host pid of its init (bwrapinfo.json's + // child-pid), which `kill -KILL` ends the whole sandbox through. + std::string flatpakInstance; + std::int64_t hostInitPid = 0; + std::string exe; + std::uint16_t port = 0; + std::string token; + std::uint32_t protocol = 0; + std::string sourceId; + // The crafter-debug client built with this app. + std::string client; + // Seconds since the epoch. + std::int64_t startedAt = 0; + }; + + // $CRAFTER_DEBUG_DIR when set (tests), else $HOME/.cache/crafter-debug/instances. + std::filesystem::path DiscoveryDirectory(); + + // An app's presence: writes the record and holds the lock while it lives. + class Registration { + public: + // Fills in instanceId, pid, the Flatpak fields, exe and startedAt. + // Throws std::system_error when the directory or files cannot be made. + explicit Registration(InstanceRecord record); + ~Registration(); + Registration(const Registration&) = delete; + Registration& operator=(const Registration&) = delete; + + const InstanceRecord& Record() const { return record; } + + private: + InstanceRecord record; + std::filesystem::path recordPath; + std::filesystem::path lockPath; + std::int32_t lockFd = -1; + }; + + struct Instance { + InstanceRecord record; + std::filesystem::path recordPath; + }; + + // Every live instance, newest first; the files of dead ones are removed. + std::vector ListInstances(); +} diff --git a/interfaces/Crafter.Debug.Core.cppm b/interfaces/Crafter.Debug.Core.cppm index 7360b9a..1f3620c 100644 --- a/interfaces/Crafter.Debug.Core.cppm +++ b/interfaces/Crafter.Debug.Core.cppm @@ -10,3 +10,5 @@ export import :Reflect; export import :Codec; export import :Result; export import :Protocol; +export import :BuildInfo; +export import :Discovery; diff --git a/project.cpp b/project.cpp index 83b4eb6..97d8d19 100644 --- a/project.cpp +++ b/project.cpp @@ -2,7 +2,8 @@ // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // Three configurations: -// Crafter.Debug.Core what the app and the client share (values, codecs) +// Crafter.Debug.Core what the app and the client share (values, codecs, +// the protocol, discovery, build info) // crafter.debug-cli the client, crafter-debug // Crafter.Debug what an app links (returned), depending on both // The client is one of the library's dependencies only so that a consumer's @@ -28,11 +29,12 @@ namespace { } // Tests of the core, linked against it alone. - constexpr std::array coreTests = { + constexpr std::array coreTests = { "ShouldRoundTripJson", "ShouldRoundTripAggregates", "ShouldReportDecodeErrorsWithPaths", "ShouldDescribeDefaultsAndRequired", + "ShouldDiscoverLiveInstances", }; // Tests of the app library. @@ -45,6 +47,65 @@ namespace { "ShouldPauseAndStep", "ShouldWaitTraceAndFollow", }; + + std::string ReadFile(const fs::path& path) { + std::ifstream in(path, std::ios::binary); + return std::string((std::istreambuf_iterator(in)), std::istreambuf_iterator()); + } + + // FNV-1a over the path and bytes of every source, in a fixed order: what + // the app and the client compare to tell they came from the same code. + std::string SourceId() { + std::vector files; + for (const std::string_view dir : { "interfaces", "implementations" }) { + std::error_code ec; + for (const fs::directory_entry& entry : fs::recursive_directory_iterator(Root() / dir, ec)) { + if (entry.is_regular_file() && (entry.path().extension() == ".cppm" || entry.path().extension() == ".cpp")) files.push_back(entry.path()); + } + } + std::ranges::sort(files); + std::uint64_t hash = 14695981039346656037ULL; + const auto mix = [&](std::string_view bytes) { + for (const char c : bytes) { + hash ^= static_cast(c); + hash *= 1099511628211ULL; + } + }; + for (const fs::path& file : files) { + mix(file.lexically_relative(Root()).generic_string()); + mix(ReadFile(file)); + } + return std::format("{:016x}", hash); + } + + void WriteIfChanged(const fs::path& file, const std::string& text) { + if (fs::exists(file) && ReadFile(file) == text) return; + fs::create_directories(file.parent_path()); + std::ofstream(file, std::ios::binary | std::ios::trunc) << text; + } + + // Generates the definitions of :BuildInfo for this variant of the core and + // returns the source to compile, without its extension. + fs::path GenerateBuildInfo(const Configuration& core, const Configuration& client) { + const fs::path source = Root() / "build" / "generated" / core.VariantId() / "Crafter.Debug.Core-BuildInfo"; + const fs::path clientPath = client.BinDir() / client.outputName; + WriteIfChanged(fs::path(source).concat(".cpp"), std::format( + "// Generated by Crafter.Debug's project.cpp whenever the sources or the client's\n" + "// path change. Not to be edited.\n" + "module Crafter.Debug.Core:BuildInfo_impl;\n" + "import std;\n" + "import :BuildInfo;\n" + "\n" + "std::string_view Crafter::Debug::Detail::SourceId() {{\n" + " return \"{}\";\n" + "}}\n" + "\n" + "std::string_view Crafter::Debug::Detail::ClientPath() {{\n" + " return R\"crafter-debug({})crafter-debug\";\n" + "}}\n", + SourceId(), clientPath.string())); + return source; + } } extern "C" Configuration CrafterBuildProject(std::span args) { @@ -61,22 +122,6 @@ extern "C" Configuration CrafterBuildProject(std::span a core.outputName = "Crafter.Debug.Core"; core.type = ConfigurationType::LibraryStatic; ApplyStandardArgs(core, args); - { - std::array interfaces = { - "interfaces/Crafter.Debug.Core-Value", - "interfaces/Crafter.Debug.Core-Reflect", - "interfaces/Crafter.Debug.Core-Codec", - "interfaces/Crafter.Debug.Core-Result", - "interfaces/Crafter.Debug.Core-Protocol", - "interfaces/Crafter.Debug.Core", - }; - std::array implementations = { - "implementations/Crafter.Debug.Core-Value", - "implementations/Crafter.Debug.Core-Codec", - "implementations/Crafter.Debug.Core-Protocol", - }; - core.GetInterfacesAndImplementations(interfaces, implementations); - } // The client, built for the library's target. Not through // ApplyStandardArgs: a consumer's --lib would make it a library. @@ -91,6 +136,27 @@ extern "C" Configuration CrafterBuildProject(std::span a client.sysroot = cfg.sysroot; client.debug = cfg.debug; client.dependencies = { &core }; + + { + std::array interfaces = { + "interfaces/Crafter.Debug.Core-Value", + "interfaces/Crafter.Debug.Core-Reflect", + "interfaces/Crafter.Debug.Core-Codec", + "interfaces/Crafter.Debug.Core-Result", + "interfaces/Crafter.Debug.Core-Protocol", + "interfaces/Crafter.Debug.Core-BuildInfo", + "interfaces/Crafter.Debug.Core-Discovery", + "interfaces/Crafter.Debug.Core", + }; + std::array implementations = { + "implementations/Crafter.Debug.Core-Value", + "implementations/Crafter.Debug.Core-Codec", + "implementations/Crafter.Debug.Core-Protocol", + "implementations/Crafter.Debug.Core-Discovery", + GenerateBuildInfo(core, client), + }; + core.GetInterfacesAndImplementations(interfaces, implementations); + } { std::array interfaces = {}; std::array implementations = { "implementations/main" }; diff --git a/tests/ShouldDiscoverLiveInstances/main.cpp b/tests/ShouldDiscoverLiveInstances/main.cpp new file mode 100644 index 0000000..562dfbf --- /dev/null +++ b/tests/ShouldDiscoverLiveInstances/main.cpp @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// Discovery across processes: an app's Registration writes its record +// (readable by its user only) and holds the lock while it lives, and +// ListInstances lists it. An app killed with SIGKILL, which never runs its +// destructor, leaves its files behind with the lock free, and the next +// listing removes them instead of offering a dead instance; one that ends +// normally removes its own. Pids cannot tell this apart: every `flatpak run` +// sandbox has its own pid namespace. Also checks the generated build info. +#include +#include +#include +#include +#include +import std; +import Crafter.Debug.Core; +using namespace Crafter::Debug; +namespace fs = std::filesystem; + +namespace { + std::int32_t failures = 0; + + void Check(bool ok, std::string_view what) { + std::println("{} {}", ok ? "PASS" : "FAIL", what); + if (!ok) ++failures; + } + + std::uint32_t Mode(const fs::path& path) { + struct stat info {}; + ::stat(path.c_str(), &info); + return info.st_mode & 0777; + } +} + +int main() { + const fs::path dir = fs::temp_directory_path() / "crafter-debug-ShouldDiscoverLiveInstances"; + fs::remove_all(dir); + ::setenv("CRAFTER_DEBUG_DIR", dir.c_str(), 1); + + std::optional one; + one.emplace(InstanceRecord{ .name = "one", .port = 4100, .token = "secret", .protocol = protocolVersion }); + const std::vector first = ListInstances(); + Check(first.size() == 1 && first[0].record.name == "one" && first[0].record.port == 4100 && first[0].record.token == "secret", "a registered instance is listed"); + Check(first.size() == 1 && first[0].record.pid == ::getpid() && !first[0].record.instanceId.empty() && first[0].record.exe.ends_with("ShouldDiscoverLiveInstances"), + "the record carries the pid, an instance id and the executable"); + Check(Mode(dir) == 0700 && first.size() == 1 && Mode(first[0].recordPath) == 0600, "the directory and record are its user's only"); + if (fs::exists("/.flatpak-info")) { + Check(!one->Record().flatpakInstance.empty() && one->Record().hostInitPid > 0, "inside a Flatpak, the sandbox's instance id and host init pid are recorded"); + } + + std::array ready{}; + ::pipe(ready.data()); + const ::pid_t child = ::fork(); + if (child == 0) { + ::close(ready[0]); + Registration two(InstanceRecord{ .name = "two" }); + const char signal = 'r'; + (void)::write(ready[1], &signal, 1); + while (true) ::pause(); + } + ::close(ready[1]); + char signal = 0; + (void)::read(ready[0], &signal, 1); + const std::vector both = ListInstances(); + Check(both.size() == 2, std::format("two live instances are listed ({})", both.size())); + fs::path twoRecord; + for (const Instance& instance : both) { + if (instance.record.name == "two") twoRecord = instance.recordPath; + } + + ::kill(child, SIGKILL); + ::waitpid(child, nullptr, 0); + Check(fs::exists(twoRecord), "a killed instance leaves its record behind"); + const std::vector afterKill = ListInstances(); + Check(afterKill.size() == 1 && afterKill[0].record.name == "one", "the next listing leaves out the killed instance"); + Check(!fs::exists(twoRecord) && !fs::exists(fs::path(twoRecord).replace_extension(".lock")), "and removes its files"); + + one.reset(); + Check(ListInstances().empty() && fs::is_empty(dir), "an instance that ends removes its own files"); + + Check(Detail::SourceId().size() == 16, std::format("the build has a source id ({})", Detail::SourceId())); + Check(Detail::ClientPath().ends_with("/crafter-debug"), std::format("the build knows its client ({})", Detail::ClientPath())); + + fs::remove_all(dir); + return failures == 0 ? 0 : 1; +}