Files
InstanceBrick--nightcap/tests/single_instance_lock_test.cc
T
2026-08-16 04:19:32 +03:00

309 lines
11 KiB
C++

#include "runtime/single_instance_lock.h"
#include <gtest/gtest.h>
#include <sys/wait.h>
#include <unistd.h>
#include <array>
#include <cerrno>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <optional>
#include <regex>
#include <string>
#include <string_view>
#include <unordered_map>
#include <utility>
namespace mocktail {
namespace runtime {
namespace {
constexpr char kNamespaceProbeDescriptor[] =
"MOCKTAIL_TEST_NAMESPACE_PROBE_DESCRIPTOR";
constexpr char kNamespaceProbeMutationAppDir[] =
"MOCKTAIL_TEST_NAMESPACE_PROBE_MUTATION_APPDIR";
constexpr char kNamespaceProbeMutationAppImage[] =
"MOCKTAIL_TEST_NAMESPACE_PROBE_MUTATION_APPIMAGE";
class MapEnvironment final : public Environment {
public:
explicit MapEnvironment(std::unordered_map<std::string, std::string> values)
: values_(std::move(values)) {}
std::optional<std::string> Get(std::string_view name) const override {
const auto found = values_.find(std::string(name));
return found == values_.end() ? std::nullopt
: std::optional<std::string>(found->second);
}
private:
std::unordered_map<std::string, std::string> values_;
};
struct NamespaceProbeOptions {
std::optional<std::string> app_image;
std::optional<std::string> app_dir;
std::optional<std::string> mutation_app_image;
std::optional<std::string> mutation_app_dir;
};
struct NamespaceProbeResult {
int wait_status = -1;
std::string output;
};
bool ConfigureChildEnvironment(
const char* name, const std::optional<std::string>& value) {
return value.has_value() ? setenv(name, value->c_str(), 1) == 0
: unsetenv(name) == 0;
}
NamespaceProbeResult RunNamespaceProbe(const NamespaceProbeOptions& options) {
NamespaceProbeResult result;
std::array<int, 2> descriptors{};
if (pipe(descriptors.data()) != 0) return result;
const std::string descriptor = std::to_string(descriptors[1]);
const pid_t child = fork();
if (child < 0) {
close(descriptors[0]);
close(descriptors[1]);
return result;
}
if (child == 0) {
close(descriptors[0]);
if (setenv(kNamespaceProbeDescriptor, descriptor.c_str(), 1) != 0 ||
!ConfigureChildEnvironment("APPIMAGE", options.app_image) ||
!ConfigureChildEnvironment("APPDIR", options.app_dir) ||
!ConfigureChildEnvironment(kNamespaceProbeMutationAppImage,
options.mutation_app_image) ||
!ConfigureChildEnvironment(kNamespaceProbeMutationAppDir,
options.mutation_app_dir)) {
_exit(125);
}
execl("/proc/self/exe", "single_instance_lock_test",
"--gtest_filter=SingleInstanceLockTest.ExecutableNamespaceProbe",
"--gtest_brief=1", nullptr);
_exit(127);
}
close(descriptors[1]);
std::array<char, 128> buffer{};
while (true) {
const ssize_t read_result =
read(descriptors[0], buffer.data(), buffer.size());
if (read_result > 0) {
result.output.append(buffer.data(),
static_cast<std::size_t>(read_result));
continue;
}
if (read_result < 0 && errno == EINTR) continue;
break;
}
close(descriptors[0]);
while (waitpid(child, &result.wait_status, 0) < 0 && errno == EINTR) {
}
return result;
}
bool WriteAll(int descriptor, std::string_view value) {
while (!value.empty()) {
const ssize_t write_result =
write(descriptor, value.data(), value.size());
if (write_result > 0) {
value.remove_prefix(static_cast<std::size_t>(write_result));
continue;
}
if (write_result < 0 && errno == EINTR) continue;
return false;
}
return true;
}
class SingleInstanceLockTest : public ::testing::Test {
protected:
void SetUp() override {
root_ = std::filesystem::temp_directory_path() /
("mocktail-instance-test-" + std::to_string(getpid()));
std::filesystem::remove_all(root_);
ASSERT_TRUE(std::filesystem::create_directories(root_));
lock_file_ = root_ / "instance.lock";
}
void TearDown() override { std::filesystem::remove_all(root_); }
std::filesystem::path root_;
std::filesystem::path lock_file_;
};
TEST_F(SingleInstanceLockTest, RejectsSecondConcurrentOwner) {
SingleInstanceLock first = SingleInstanceLock::Acquire(lock_file_);
ASSERT_TRUE(first.acquired()) << first.error();
SingleInstanceLock second = SingleInstanceLock::Acquire(lock_file_);
EXPECT_TRUE(second.already_running());
EXPECT_FALSE(second.acquired());
}
TEST_F(SingleInstanceLockTest, ReleasesLockWithOwnerLifetime) {
{
SingleInstanceLock first = SingleInstanceLock::Acquire(lock_file_);
ASSERT_TRUE(first.acquired()) << first.error();
}
SingleInstanceLock replacement = SingleInstanceLock::Acquire(lock_file_);
EXPECT_TRUE(replacement.acquired()) << replacement.error();
}
TEST_F(SingleInstanceLockTest, UsesStableExecutableNamespace) {
const std::string first = CurrentExecutableInstanceKey();
const std::string second = CurrentExecutableInstanceKey();
EXPECT_EQ(first, second);
EXPECT_TRUE(std::regex_match(first, std::regex("^[0-9a-f]{16}$")));
}
TEST_F(SingleInstanceLockTest, ExecutableNamespaceProbe) {
const char* descriptor_value = std::getenv(kNamespaceProbeDescriptor);
if (descriptor_value == nullptr) {
EXPECT_TRUE(std::regex_match(CurrentExecutableInstanceKey(),
std::regex("^[0-9a-f]{16}$")));
return;
}
char* descriptor_end = nullptr;
const long descriptor =
std::strtol(descriptor_value, &descriptor_end, 10);
ASSERT_NE(descriptor_end, descriptor_value);
ASSERT_EQ(*descriptor_end, '\0');
ASSERT_GE(descriptor, 0);
std::string output = CurrentExecutableInstanceKey();
const char* mutation_app_image =
std::getenv(kNamespaceProbeMutationAppImage);
const char* mutation_app_dir = std::getenv(kNamespaceProbeMutationAppDir);
if (mutation_app_image != nullptr && mutation_app_dir != nullptr) {
ASSERT_EQ(setenv("APPIMAGE", mutation_app_image, 1), 0);
ASSERT_EQ(setenv("APPDIR", mutation_app_dir, 1), 0);
output += "\n" + CurrentExecutableInstanceKey();
}
ASSERT_TRUE(WriteAll(static_cast<int>(descriptor), output));
ASSERT_EQ(close(static_cast<int>(descriptor)), 0);
}
TEST_F(SingleInstanceLockTest, SeparatesValidatedAppImageInstallationPaths) {
std::error_code error;
const std::filesystem::path executable =
std::filesystem::canonical("/proc/self/exe", error);
ASSERT_FALSE(error) << error.message();
const std::string app_dir = executable.parent_path().string();
const NamespaceProbeResult first = RunNamespaceProbe(
{{"/tmp/mocktail-one.AppImage"}, {app_dir}, std::nullopt, std::nullopt});
const NamespaceProbeResult second = RunNamespaceProbe(
{{"/tmp/mocktail-two.AppImage"}, {app_dir}, std::nullopt, std::nullopt});
ASSERT_TRUE(WIFEXITED(first.wait_status));
ASSERT_EQ(WEXITSTATUS(first.wait_status), 0);
ASSERT_TRUE(WIFEXITED(second.wait_status));
ASSERT_EQ(WEXITSTATUS(second.wait_status), 0);
EXPECT_NE(first.output, second.output);
}
TEST_F(SingleInstanceLockTest, IgnoresInheritedAppImageOutsideAppDir) {
const std::filesystem::path foreign_app_dir = root_ / "foreign-appdir";
ASSERT_TRUE(std::filesystem::create_directories(foreign_app_dir));
const NamespaceProbeResult baseline = RunNamespaceProbe({});
const NamespaceProbeResult inherited = RunNamespaceProbe(
{{"/tmp/foreign-browser.AppImage"}, {foreign_app_dir.string()},
std::nullopt, std::nullopt});
const NamespaceProbeResult missing_app_dir = RunNamespaceProbe(
{{"/tmp/foreign-browser.AppImage"}, std::nullopt, std::nullopt,
std::nullopt});
ASSERT_TRUE(WIFEXITED(baseline.wait_status));
ASSERT_EQ(WEXITSTATUS(baseline.wait_status), 0);
ASSERT_TRUE(WIFEXITED(inherited.wait_status));
ASSERT_EQ(WEXITSTATUS(inherited.wait_status), 0);
ASSERT_TRUE(WIFEXITED(missing_app_dir.wait_status));
ASSERT_EQ(WEXITSTATUS(missing_app_dir.wait_status), 0);
EXPECT_EQ(inherited.output, baseline.output);
EXPECT_EQ(missing_app_dir.output, baseline.output);
}
TEST_F(SingleInstanceLockTest, CachesExecutableNamespaceForProcessLifetime) {
std::error_code error;
const std::filesystem::path executable =
std::filesystem::canonical("/proc/self/exe", error);
ASSERT_FALSE(error) << error.message();
const NamespaceProbeResult result = RunNamespaceProbe(
{std::nullopt, std::nullopt, {"/tmp/mutated-mocktail.AppImage"},
{executable.parent_path().string()}});
ASSERT_TRUE(WIFEXITED(result.wait_status));
ASSERT_EQ(WEXITSTATUS(result.wait_status), 0);
const std::size_t separator = result.output.find('\n');
ASSERT_NE(separator, std::string::npos);
EXPECT_EQ(result.output.substr(0, separator),
result.output.substr(separator + 1));
}
TEST_F(SingleInstanceLockTest, RejectsSymlinkLockFile) {
const std::filesystem::path target = root_ / "target";
std::ofstream(target) << "not a lock";
std::filesystem::create_symlink(target, lock_file_);
ASSERT_TRUE(std::filesystem::is_symlink(lock_file_));
SingleInstanceLock lock = SingleInstanceLock::Acquire(lock_file_);
EXPECT_EQ(lock.status(), SingleInstanceLock::Status::kError);
EXPECT_FALSE(lock.error().empty());
}
TEST_F(SingleInstanceLockTest, IsolatedCanaryUsesStateRootLock) {
const std::filesystem::path data = root_ / "data";
const std::filesystem::path cache = root_ / "cache";
const std::filesystem::path state = root_ / "state";
ASSERT_TRUE(std::filesystem::create_directories(data));
ASSERT_TRUE(std::filesystem::create_directories(cache));
ASSERT_TRUE(std::filesystem::create_directories(state));
const MapEnvironment environment({
{"HOME", (root_ / "home").string()},
{"MOCKTAIL_DATA_ROOT", data.string()},
{"MOCKTAIL_CACHE_ROOT", cache.string()},
{"MOCKTAIL_STATE_ROOT", state.string()},
{"ROBLOX_LIB_PATH", (root_ / "libroblox.so").string()},
{"MOCKTAIL_ASSET_PATH", (root_ / "assets").string()},
{"MOCKTAIL_SKIP_UPDATE_CHECK", "1"},
{"MOCKTAIL_AUTO_EXIT_AFTER_PRESENT_MS", "5000"},
{"MOCKTAIL_ISOLATED_CANARY", "1"},
});
const RuntimePaths paths = RuntimePaths::FromEnvironment(environment);
SingleInstanceLock first =
SingleInstanceLock::AcquireForLaunch(environment, paths);
ASSERT_TRUE(first.acquired()) << first.error();
EXPECT_EQ(first.path(), state / "instance.lock");
SingleInstanceLock second =
SingleInstanceLock::AcquireForLaunch(environment, paths);
EXPECT_TRUE(second.already_running());
}
TEST_F(SingleInstanceLockTest, RejectsCanaryWithSharedRuntimeRoots) {
const MapEnvironment environment({
{"HOME", (root_ / "home").string()},
{"MOCKTAIL_DATA_ROOT", root_.string()},
{"MOCKTAIL_CACHE_ROOT", root_.string()},
{"MOCKTAIL_STATE_ROOT", root_.string()},
{"ROBLOX_LIB_PATH", (root_ / "libroblox.so").string()},
{"MOCKTAIL_ASSET_PATH", (root_ / "assets").string()},
{"MOCKTAIL_SKIP_UPDATE_CHECK", "1"},
{"MOCKTAIL_AUTO_EXIT_AFTER_PRESENT_MS", "5000"},
{"MOCKTAIL_ISOLATED_CANARY", "1"},
});
const RuntimePaths paths = RuntimePaths::FromEnvironment(environment);
SingleInstanceLock lock =
SingleInstanceLock::AcquireForLaunch(environment, paths);
EXPECT_EQ(lock.status(), SingleInstanceLock::Status::kError);
EXPECT_NE(lock.error().find("distinct absolute siblings"), std::string::npos);
}
} // namespace
} // namespace runtime
} // namespace mocktail