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; +}