HostCacheSourceStamp already edits a module source with a backdated mtime and expects the PCM cache to be rebuilt. Its own header names the real danger, a project.so compiled against declarations that no longer match the process it is loaded into, but it never looked at the project.so. It now records the file's mtime before the edit and expects a newer one after it, with project.cpp untouched, so only the stamp can force the rebuild. Fails without the previous commit: the stamp was the version string alone and the project.so was reused. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
166 lines
6.9 KiB
C++
166 lines
6.9 KiB
C++
// SPDX-License-Identifier: LGPL-3.0-only
|
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
|
|
|
#include <stdlib.h>
|
|
import std;
|
|
import Crafter.Build;
|
|
namespace fs = std::filesystem;
|
|
using namespace Crafter;
|
|
|
|
// The host PCM cache is keyed by `<target>-<march>` alone, so every
|
|
// crafter-build on a machine — a package install and a working checkout, two
|
|
// checkouts of different versions — writes the same `Crafter.Build-*.pcm`
|
|
// files. Freshness was a per-file mtime comparison, which says "the PCM is
|
|
// newer than my source, reuse it" whether or not it was built from *my*
|
|
// source. The loser then compiled its project.cpp against the other install's
|
|
// declarations: no error, and a project.so built against a layout that doesn't
|
|
// match the library it's loaded into — the same silent shape as issue #27.
|
|
//
|
|
// So the invalidation has to key on the source bytes, and this test asserts it
|
|
// notices a change the mtime rule provably cannot see: different content with a
|
|
// deliberately *older* timestamp.
|
|
namespace {
|
|
std::int32_t Failures = 0;
|
|
|
|
void Check(bool cond, std::string_view msg) {
|
|
if (!cond) {
|
|
std::println(std::cerr, "FAIL: {}", msg);
|
|
++Failures;
|
|
}
|
|
}
|
|
|
|
// The cache subdirectory is named for the host target and march; glob for
|
|
// it rather than reconstructing the naming rule here.
|
|
fs::path FindModuleCacheDir(const fs::path& cacheRoot) {
|
|
for (const fs::directory_entry& entry : fs::directory_iterator(cacheRoot / "crafter.build")) {
|
|
if (entry.is_directory()) return entry.path();
|
|
}
|
|
return {};
|
|
}
|
|
|
|
std::string ReadStamp(const fs::path& moduleCacheDir) {
|
|
std::ifstream in(moduleCacheDir / "crafter-build-sources.stamp");
|
|
std::string stamp;
|
|
std::getline(in, stamp);
|
|
return stamp;
|
|
}
|
|
}
|
|
|
|
int main() {
|
|
fs::path installedHome = GetCrafterBuildHome();
|
|
if (!fs::exists(installedHome / "Crafter.Build.cppm")) {
|
|
std::println(std::cerr, "SKIP: no module sources at {}", installedHome.string());
|
|
return 77;
|
|
}
|
|
|
|
fs::path scratch = fs::temp_directory_path() / "crafter-build-host-cache-source-stamp";
|
|
std::error_code ec;
|
|
fs::remove_all(scratch, ec);
|
|
fs::create_directories(scratch);
|
|
|
|
// A private copy of the module sources, so the mutation below can't touch
|
|
// the real install, and a cold private cache so the first LoadProject has
|
|
// to populate it.
|
|
fs::path home = scratch / "share" / "crafter-build";
|
|
fs::create_directories(home.parent_path());
|
|
fs::copy(installedHome, home, fs::copy_options::recursive);
|
|
setenv("CRAFTER_BUILD_HOME", home.string().c_str(), 1);
|
|
|
|
fs::path cacheRoot = scratch / "cache";
|
|
fs::create_directories(cacheRoot);
|
|
setenv("XDG_CACHE_HOME", cacheRoot.string().c_str(), 1);
|
|
|
|
fs::path projectDir = scratch / "project";
|
|
fs::create_directories(projectDir);
|
|
fs::path projectFile = projectDir / "project.cpp";
|
|
std::ofstream(projectFile) <<
|
|
"import std;\n"
|
|
"import Crafter.Build;\n"
|
|
"using namespace Crafter;\n"
|
|
"extern \"C\" Configuration CrafterBuildProject(std::span<const std::string_view>) {\n"
|
|
" Configuration cfg;\n"
|
|
" cfg.path = \"./\";\n"
|
|
" cfg.name = \"stamped\";\n"
|
|
" cfg.outputName = \"stamped\";\n"
|
|
" cfg.target = HostTarget();\n"
|
|
" cfg.type = ConfigurationType::Executable;\n"
|
|
" return cfg;\n"
|
|
"}\n";
|
|
|
|
std::array<std::string_view, 0> noArgs = {};
|
|
|
|
try {
|
|
(void)LoadProject(projectFile, noArgs);
|
|
} catch (const std::exception& e) {
|
|
std::println(std::cerr, "FAIL: first LoadProject threw: {}", e.what());
|
|
return 1;
|
|
}
|
|
|
|
fs::path moduleCacheDir = FindModuleCacheDir(cacheRoot);
|
|
Check(!moduleCacheDir.empty(), "a module cache directory was created");
|
|
if (moduleCacheDir.empty()) return 1;
|
|
|
|
fs::path probePcm = moduleCacheDir / "Crafter.Build-Clang.pcm";
|
|
Check(fs::exists(probePcm), "the cold run populated the cache");
|
|
if (!fs::exists(probePcm)) return 1;
|
|
|
|
std::string firstStamp = ReadStamp(moduleCacheDir);
|
|
Check(!firstStamp.empty(), "the cold run recorded a source stamp");
|
|
fs::file_time_type afterCold = fs::last_write_time(probePcm);
|
|
|
|
// Unchanged sources must be a no-op — the stamp is an invalidation key, not
|
|
// an excuse to precompile eleven modules on every invocation.
|
|
try {
|
|
(void)LoadProject(projectFile, noArgs);
|
|
} catch (const std::exception& e) {
|
|
std::println(std::cerr, "FAIL: warm LoadProject threw: {}", e.what());
|
|
return 1;
|
|
}
|
|
Check(fs::last_write_time(probePcm) == afterCold, "unchanged sources do not rebuild the cache");
|
|
Check(ReadStamp(moduleCacheDir) == firstStamp, "unchanged sources keep the same stamp");
|
|
|
|
// The compiled project file is the other half of the same hazard: it is
|
|
// loaded into this process and must have been compiled against the
|
|
// declarations this process was built from.
|
|
fs::path projectSo;
|
|
for (const fs::directory_entry& entry : fs::recursive_directory_iterator(projectDir)) {
|
|
if (entry.path().filename() == "project.so") projectSo = entry.path();
|
|
}
|
|
Check(!projectSo.empty(), "the project file was compiled to a project.so");
|
|
if (projectSo.empty()) return 1;
|
|
const fs::file_time_type soBeforeEdit = fs::last_write_time(projectSo);
|
|
|
|
// Now the case the mtime rule cannot see. The edit lands in a module the
|
|
// probe does not name, because the cached PCMs import each other: a change
|
|
// anywhere in the set invalidates all of them.
|
|
fs::path edited = home / "Crafter.Build-Progress.cppm";
|
|
{
|
|
std::ofstream append(edited, std::ios::app);
|
|
append << "\n// Content change with a backdated timestamp.\n";
|
|
}
|
|
fs::last_write_time(edited, afterCold - std::chrono::hours(24));
|
|
Check(fs::last_write_time(edited) < fs::last_write_time(probePcm), "the edited source really is older than the cached PCM");
|
|
|
|
try {
|
|
(void)LoadProject(projectFile, noArgs);
|
|
} catch (const std::exception& e) {
|
|
std::println(std::cerr, "FAIL: LoadProject after the edit threw: {}", e.what());
|
|
return 1;
|
|
}
|
|
|
|
Check(fs::last_write_time(probePcm) > afterCold, "a content change rebuilds the cache even when the source's mtime is older");
|
|
Check(ReadStamp(moduleCacheDir) != firstStamp, "the recorded stamp follows the sources");
|
|
// project.cpp itself is untouched, so only the stamp can force this. It
|
|
// once covered the version string alone (the rest sat behind a NUL in a
|
|
// format string): a project.so built against a class of another size was
|
|
// reused and corrupted the heap when it filled one in.
|
|
Check(fs::last_write_time(projectSo) > soBeforeEdit, "changed declarations recompile project.so even though project.cpp is unchanged");
|
|
|
|
fs::remove_all(scratch, ec);
|
|
|
|
if (Failures > 0) {
|
|
std::println(std::cerr, "{} assertions failed", Failures);
|
|
return 1;
|
|
}
|
|
return 0;
|
|
}
|