Crafter.Build/tests/ConcurrentExternalFetch/main.cpp
Jorijn van der Graaf 7b548b9ce4
test(external): race BuildExternal as well as GitFetch
ConcurrentExternalFetch raced eight threads through GitFetch only.
BuildExternal takes the same lock on a line of its own, so that line
could go without any test noticing. Now that the configurations of one
pass share a single BuildExternal, its lock is what keeps two
crafter-builds, or two passes, out of each other's pulls.

The same threads now fetch through both, each into a clone of its own,
and both clones must end at the last round. With the lock in
BuildExternal removed, the test failed in two of three runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 02:09:24 +01:00

113 lines
4.8 KiB
C++

// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
import std;
import Crafter.Build;
namespace fs = std::filesystem;
using namespace Crafter;
// Builds that run at the same time share their dependencies' clones in the
// cache, and two crafter-builds can run at once. Fetching a clone that
// already exists ran `git pull` (and the CMake build) in it with nothing to
// keep a second fetch out, and the second git fails on the first one's
// index.lock. A clone's fetch and build now hold a lock of their own. Within
// one run, the configurations that declare the same dependency share one
// fetch of it instead (SharedExternalFetch).
//
// Eight threads fetch the same tracked branch of a local repository at
// once, a few rounds over, the branch moving between rounds so every pull
// has work to do; every fetch has to succeed. Each thread fetches through
// GitFetch and through BuildExternal, which every build of an
// ExternalDependency calls, each into a clone of its own. Everything is
// local; XDG_CACHE_HOME keeps the clones out of the real cache.
extern "C" int setenv(const char* name, const char* value, int overwrite);
namespace {
std::int32_t failures = 0;
void Check(bool cond, std::string_view msg) {
if (!cond) {
std::println(std::cerr, "FAIL: {}", msg);
++failures;
}
}
void Git(const fs::path& dir, std::string_view args) {
const std::string command = std::format("git -C {} {}", ShellQuote(dir.string()), args);
auto result = RunCommandWithTimeout(command, std::chrono::seconds(60));
if (result.exitCode != 0) throw std::runtime_error(std::format("{} failed: {}", command, result.output));
}
void Commit(const fs::path& repo, std::uint32_t round) {
std::ofstream(repo / "round.txt", std::ios::binary | std::ios::trunc) << round;
Git(repo, "add round.txt");
Git(repo, std::format("commit -q -m round-{}", round));
}
std::uint32_t Round(const fs::path& clone) {
std::ifstream in(clone / "round.txt");
std::uint32_t round = 0;
in >> round;
return round;
}
}
int main() {
const fs::path scratch = fs::temp_directory_path() / "crafter-build-concurrent-external-fetch";
fs::remove_all(scratch);
fs::create_directories(scratch);
setenv("XDG_CACHE_HOME", (scratch / "cache").c_str(), 1);
setenv("GIT_CONFIG_NOSYSTEM", "1", 1);
setenv("GIT_CONFIG_GLOBAL", "/dev/null", 1);
for (const char* name : { "GIT_AUTHOR_NAME", "GIT_COMMITTER_NAME" }) setenv(name, "crafter-build test", 1);
for (const char* name : { "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_EMAIL" }) setenv(name, "test@example.invalid", 1);
const fs::path repo = scratch / "shared";
fs::create_directories(repo);
Git(repo, "init -q -b main");
Commit(repo, 0);
const GitSource source { .url = std::format("file://{}", repo.string()), .branch = "main" };
const fs::path clone = GitFetch(source);
// A clone of its own: BuildExternal keys the cache differently.
ExternalDependency dep;
dep.name = "shared";
dep.source = source;
const std::string cloned = BuildExternal(dep, HostTarget());
Check(cloned.empty(), std::format("BuildExternal clones the repository: {}", cloned));
constexpr std::uint32_t threads = 8;
constexpr std::uint32_t rounds = 4;
std::vector<std::string> errors;
std::mutex errorsMutex;
for (std::uint32_t round = 1; round <= rounds; ++round) {
Commit(repo, round);
std::vector<std::jthread> fetchers;
for (std::uint32_t i = 0; i < threads; ++i) {
fetchers.emplace_back([&] {
std::string error;
try {
(void)GitFetch(source);
error = BuildExternal(dep, HostTarget());
} catch (const std::exception& e) {
error = e.what();
}
if (error.empty()) return;
std::lock_guard lock(errorsMutex);
errors.push_back(std::move(error));
});
}
}
Check(errors.empty(), std::format("{} threads fetching at once, {} rounds: every fetch succeeds ({} failed{}{})", threads, rounds, errors.size(), errors.empty() ? "" : ", the first: ", errors.empty() ? "" : errors.front()));
Check(Round(clone) == rounds, std::format("and GitFetch's clone ends at the last round ({})", Round(clone)));
const fs::path externalClone = ExternalCloneDir(dep, HostTarget());
Check(Round(externalClone) == rounds, std::format("and so does BuildExternal's ({})", Round(externalClone)));
std::error_code ec;
fs::remove_all(scratch, ec);
if (failures > 0) {
std::println(std::cerr, "{} assertions failed", failures);
return 1;
}
return 0;
}