Crafter.Debug/implementations/Crafter.Debug.Core-Discovery.cpp
Jorijn van der Graaf 37e0d9b9ae
feat(discovery): find live instances across sandboxes; build info
Each app writes $HOME/.cache/crafter-debug/instances/<id>.json (0600,
in a 0700 directory) and holds an flock on <id>.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 <noreply@anthropic.com>
2026-11-01 01:56:31 +01:00

171 lines
6.9 KiB
C++

// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module;
#include <errno.h>
#include <fcntl.h>
#include <sys/file.h>
#include <unistd.h>
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<char>(in)), std::istreambuf_iterator<char>());
}
// 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<std::size_t>(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::seconds>(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<Instance> Crafter::Debug::ListInstances() {
std::vector<Instance> 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;
}