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>
This commit is contained in:
parent
bd64c44fc5
commit
37e0d9b9ae
6 changed files with 433 additions and 18 deletions
87
tests/ShouldDiscoverLiveInstances/main.cpp
Normal file
87
tests/ShouldDiscoverLiveInstances/main.cpp
Normal file
|
|
@ -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 <signal.h>
|
||||
#include <stdlib.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
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<Registration> one;
|
||||
one.emplace(InstanceRecord{ .name = "one", .port = 4100, .token = "secret", .protocol = protocolVersion });
|
||||
const std::vector<Instance> 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<std::int32_t, 2> 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<Instance> 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<Instance> 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;
|
||||
}
|
||||
Loading…
Reference in a new issue