Crafter.Build/tests/ConcurrentCacheRace/main.cpp
catbot 96d1df9233
All checks were successful
CI / build-test-release (pull_request) Successful in 11m50s
fix: atomic-rename host-cache PCMs to close concurrent-build race
Two crafter-build invocations sharing XDG_CACHE_HOME used to clobber each
other's writes to <cache>/<target>-<march>/std.pcm and the
Crafter.Build-*.pcm modules: each LoadProject path wrote directly to the
final path, so a reader could see a half-written file and die with
"malformed or corrupted precompiled file: 'can't skip to bit X from Y'"
(issue #14). Every BuildStdPcm / EnsureCrafterBuildPcms write now goes via
<final>.tmp.<pid>.<seq> and atomic-renames into place; concurrent writers
always see either the old or the new file, never torn bytes. The mingw-on-
Linux std.cppm copy is per-PID for the same reason. Adds a regression test
(ConcurrentCacheRace) that races four LoadProject() calls against a cold
scratch cache — reproduces the race 5/5 without the fix and passes 5/5
with it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 16:36:45 +00:00

117 lines
4.1 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#include <stdlib.h>
import std;
import Crafter.Build;
namespace fs = std::filesystem;
using namespace Crafter;
// Regression for the host-cache write race: when several crafter-build
// invocations shared the same XDG_CACHE_HOME, two LoadProject() calls would
// clobber each other's writes to `<cache>/<target>-<march>/std.pcm` and the
// `Crafter.Build-*.pcm` host modules. The loser's clang would then read a
// torn file via -fprebuilt-module-path and die with "malformed or corrupted
// precompiled file: 'can't skip to bit X from Y'". The fix routes every PCM
// write through a per-process temp + atomic rename so a reader always sees
// either the old or the new file, never a half-written one.
//
// LoadProject() is the right surface to test because it runs the full host
// bootstrap (BuildStdPcm + EnsureCrafterBuildPcms × the Crafter.Build-*
// modules), giving every concurrent thread enough writing-to-the-same-paths
// time for the race to surface. A pure BuildStdPcm test wouldn't — the std
// PCM write is too short to overlap reliably between threads.
namespace {
fs::path FindCrafterBuildHome() {
if (const char* env = std::getenv("CRAFTER_BUILD_HOME"); env && *env) {
return env;
}
for (const char* candidate : {
"/usr/local/share/crafter-build",
"/usr/share/crafter-build",
}) {
if (fs::exists(fs::path(candidate) / "Crafter.Build.cppm")) {
return candidate;
}
}
return {};
}
void WriteFile(const fs::path& p, std::string_view contents) {
std::ofstream out(p);
out << contents;
}
}
int main() {
fs::path home = FindCrafterBuildHome();
if (home.empty()) {
std::println(std::cerr,
"SKIP: no installed share/crafter-build found and CRAFTER_BUILD_HOME unset");
return 77;
}
setenv("CRAFTER_BUILD_HOME", home.string().c_str(), 1);
fs::path scratch = fs::temp_directory_path() / "crafter-build-concurrent-cache-race";
std::error_code ec;
fs::remove_all(scratch, ec);
fs::create_directories(scratch);
// Cold scratch cache. setenv before any thread starts so every thread's
// GetCacheDir() call sees the same override.
fs::path cacheDir = scratch / "cache";
fs::create_directories(cacheDir);
setenv("XDG_CACHE_HOME", cacheDir.string().c_str(), 1);
constexpr int N = 4;
std::vector<fs::path> projects;
for (int i = 0; i < N; ++i) {
fs::path dir = scratch / std::format("p{}", i);
fs::create_directories(dir);
WriteFile(dir / "project.cpp", std::format(
"import std;\n"
"import Crafter.Build;\n"
"namespace fs = std::filesystem;\n"
"using namespace Crafter;\n"
"extern \"C\" Configuration CrafterBuildProject(std::span<const std::string_view>) {{\n"
" Configuration cfg;\n"
" cfg.path = \"./\";\n"
" cfg.name = \"p{}\";\n"
" cfg.outputName = \"p{}\";\n"
" cfg.target = HostTarget();\n"
" cfg.type = ConfigurationType::Executable;\n"
" return cfg;\n"
"}}\n", i, i));
projects.push_back(dir / "project.cpp");
}
std::array<std::string, N> errors;
std::vector<std::thread> threads;
threads.reserve(N);
for (int i = 0; i < N; ++i) {
threads.emplace_back([&, i]() {
try {
std::array<std::string_view, 0> args = {};
(void)LoadProject(projects[i], args);
} catch (const std::exception& e) {
errors[i] = e.what();
}
});
}
for (std::thread& t : threads) t.join();
int failures = 0;
for (int i = 0; i < N; ++i) {
if (!errors[i].empty()) {
std::println(std::cerr, "FAIL p{}: {}", i, errors[i]);
++failures;
}
}
fs::remove_all(scratch, ec);
if (failures > 0) {
std::println(std::cerr,
"{}/{} concurrent LoadProject() invocations failed (host-cache race)",
failures, N);
return 1;
}
return 0;
}