fix: key the host PCM cache on source content, add clean, hash project args
Three follow-ons to the stale-build report, all cases of an identity not capturing something that changes the output. The host PCM cache under <cache>/crafter.build/<target>-<march>/ is shared by every crafter-build on the machine, and freshness was a per-file mtime comparison. That cannot tell "this PCM is newer than my source" from "this PCM was built from different sources that happen to be newer", so a package install and a working checkout — or two checkouts of different versions — silently compiled their project.cpp against each other's declarations. Invalidation now keys on a stamp over the bytes of every module source, which also covers the case one file's mtime never could: the cached PCMs import each other, so a change to :Interface invalidates :Clang's PCM with Crafter.Build-Clang.cppm untouched. Project args ApplyStandardArgs does not itself interpret are now folded into VariantId. Such a flag typically decides what gets compiled or bundled — the report's example is --no-webgpu dropping entries from cfg.files — and without it both settings shared one bin dir and interleaved their outputs there, leaving a bundle matching neither. Sorted and deduplicated so flag order doesn't split the cache, and inherited by test Configurations. `crafter-build clean` removes the project's bin/ and build/ trees. It deliberately does not load project.cpp: cleaning is most often reached when something is already wrong, and a clean that first needs the project to compile is useless exactly then.
This commit is contained in:
parent
13697cd026
commit
e8fde57582
9 changed files with 439 additions and 15 deletions
79
tests/CleanProject/main.cpp
Normal file
79
tests/CleanProject/main.cpp
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
// 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;
|
||||
|
||||
// `crafter-build clean` exists so the fix for a stale build is discoverable
|
||||
// rather than folklore. It must not need the project to load: the state you most
|
||||
// want to clear is one where the build is already broken.
|
||||
namespace {
|
||||
std::int32_t Failures = 0;
|
||||
|
||||
void Check(bool cond, std::string_view msg) {
|
||||
if (!cond) {
|
||||
std::println(std::cerr, "FAIL: {}", msg);
|
||||
++Failures;
|
||||
}
|
||||
}
|
||||
|
||||
fs::path StageProject(std::string_view leaf) {
|
||||
fs::path root = fs::temp_directory_path() / std::format("crafter-build-clean-{}", leaf);
|
||||
fs::remove_all(root);
|
||||
fs::create_directories(root / "bin" / "app-x86_64-pc-linux-gnu-native-native-0badf00d");
|
||||
fs::create_directories(root / "build" / "app-x86_64-pc-linux-gnu-native-native-0badf00d");
|
||||
std::ofstream(root / "bin" / "app-x86_64-pc-linux-gnu-native-native-0badf00d" / "app") << "binary";
|
||||
std::ofstream(root / "build" / "app-x86_64-pc-linux-gnu-native-native-0badf00d" / "main_impl.o") << "object";
|
||||
std::ofstream(root / "keep-me.txt") << "untouched";
|
||||
return root;
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
{
|
||||
// A project file that could never compile: clean has to work anyway.
|
||||
fs::path root = StageProject("broken");
|
||||
fs::path projectFile = root / "project.cpp";
|
||||
std::ofstream(projectFile) << "this is not valid C++ at all\n";
|
||||
|
||||
std::vector<fs::path> removed = CleanProject(projectFile);
|
||||
Check(removed.size() == 2, std::format("both trees reported removed, got {}", removed.size()));
|
||||
Check(!fs::exists(root / "bin"), "bin/ is gone");
|
||||
Check(!fs::exists(root / "build"), "build/ is gone");
|
||||
Check(fs::exists(root / "keep-me.txt"), "unrelated files in the project root are left alone");
|
||||
Check(fs::exists(projectFile), "the project file itself is left alone");
|
||||
}
|
||||
|
||||
{
|
||||
// Idempotent, and quiet about it — nothing to remove is not an error.
|
||||
fs::path root = StageProject("twice");
|
||||
fs::path projectFile = root / "project.cpp";
|
||||
std::ofstream(projectFile) << "\n";
|
||||
|
||||
CleanProject(projectFile);
|
||||
std::vector<fs::path> second = CleanProject(projectFile);
|
||||
Check(second.empty(), "a second clean reports nothing removed");
|
||||
}
|
||||
|
||||
{
|
||||
// Resolved relative to the project file, not the cwd, so --project=
|
||||
// cleans the project it names.
|
||||
fs::path root = StageProject("elsewhere");
|
||||
fs::path projectFile = root / "project.cpp";
|
||||
std::ofstream(projectFile) << "\n";
|
||||
fs::path cwdBin = fs::current_path() / "bin";
|
||||
bool cwdBinExisted = fs::exists(cwdBin);
|
||||
|
||||
CleanProject(projectFile);
|
||||
Check(!fs::exists(root / "bin"), "the named project's bin/ is gone");
|
||||
Check(fs::exists(cwdBin) == cwdBinExisted, "the cwd's bin/ is untouched");
|
||||
}
|
||||
|
||||
if (Failures > 0) {
|
||||
std::println(std::cerr, "{} assertions failed", Failures);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
150
tests/HostCacheSourceStamp/main.cpp
Normal file
150
tests/HostCacheSourceStamp/main.cpp
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
// 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");
|
||||
|
||||
// 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");
|
||||
|
||||
fs::remove_all(scratch, ec);
|
||||
|
||||
if (Failures > 0) {
|
||||
std::println(std::cerr, "{} assertions failed", Failures);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -83,6 +83,31 @@ int main() {
|
|||
Check(!q.Get("--other=").has_value(), "ArgQuery::Get returns nullopt for absent prefix");
|
||||
}
|
||||
|
||||
// Flags ApplyStandardArgs doesn't itself interpret are collected into
|
||||
// cfg.projectArgs, which feeds VariantId — a project flag that changes what
|
||||
// gets compiled has to move the output directory with it.
|
||||
{
|
||||
Configuration cfg;
|
||||
std::array<std::string_view, 5> raw = {
|
||||
"--debug", "--no-webgpu", "--target=aarch64-linux-gnu", "--feature=fancy", "bare",
|
||||
};
|
||||
ApplyStandardArgs(cfg, raw);
|
||||
std::vector<std::string> expected = { "--feature=fancy", "--no-webgpu", "bare" };
|
||||
Check(cfg.projectArgs == expected, std::format("only unrecognised args are collected, sorted; got [{}]", std::format("{}", std::views::join_with(cfg.projectArgs, std::string(", ")) | std::ranges::to<std::string>())));
|
||||
}
|
||||
|
||||
// Order and repetition must not perturb the set — otherwise the same build
|
||||
// spelled two ways would land in two directories.
|
||||
{
|
||||
Configuration a;
|
||||
Configuration b;
|
||||
std::array<std::string_view, 2> forward = { "--alpha", "--beta" };
|
||||
std::array<std::string_view, 3> reversed = { "--beta", "--alpha", "--beta" };
|
||||
ApplyStandardArgs(a, forward);
|
||||
ApplyStandardArgs(b, reversed);
|
||||
Check(a.projectArgs == b.projectArgs, "projectArgs is order- and duplicate-insensitive");
|
||||
}
|
||||
|
||||
if (Failures > 0) {
|
||||
std::println(std::cerr, "{} assertions failed", Failures);
|
||||
return 1;
|
||||
|
|
|
|||
|
|
@ -96,6 +96,25 @@ int main() {
|
|||
b.march = "x86-64-v3";
|
||||
Check(a.VariantId() != b.VariantId(), "march change perturbs VariantId");
|
||||
}
|
||||
{
|
||||
// A project flag the framework can't interpret still decides what gets
|
||||
// compiled and bundled, so it has to key into VariantId. Otherwise both
|
||||
// settings share one bin dir and interleave their outputs there — an
|
||||
// index.html from one alongside a script from the other.
|
||||
Configuration a = MakeBase();
|
||||
Configuration b = MakeBase();
|
||||
b.projectArgs.push_back("--no-webgpu");
|
||||
Check(a.VariantId() != b.VariantId(), "project arg perturbs VariantId");
|
||||
Check(a.BinDir() != b.BinDir(), "project arg yields a distinct BinDir");
|
||||
}
|
||||
{
|
||||
// Distinct project args must not collide with each other either.
|
||||
Configuration a = MakeBase();
|
||||
a.projectArgs.push_back("--no-webgpu");
|
||||
Configuration b = MakeBase();
|
||||
b.projectArgs.push_back("--no-audio");
|
||||
Check(a.VariantId() != b.VariantId(), "different project args yield different VariantId");
|
||||
}
|
||||
{
|
||||
// PcmDir() differs between Executable (in BuildDir) and Library
|
||||
// (in BinDir) — Library PCMs land in the installable bin dir so
|
||||
|
|
|
|||
Loading…
Reference in a new issue