fix: rebuild and relink on what changed, submodules at their revisions, and mingw found where it is
Some checks failed
Flatpak / flatpak (push) Failing after 1m12s
Some checks failed
Flatpak / flatpak (push) Failing after 1m12s
- Shaders record the files glslang opened (a depfile) and their settings
(source path, stage, entry point, defines, include directories) in the
build directory, and rebuild when an included file or a setting changes,
not only the shader's own source.
- An executable relinks when its link command changed, not only when an
object is newer: a link flag in project.cpp, or a dependency's library
that moved (an ExternalDependency built into a new cache directory). The
command is recorded beside the objects; its flags come from ordered sets,
so the order the dependency threads finish in can't change it.
- An external's newest artifact counts its libDirs and versioned .so names.
- Submodules are checked out at the revisions the fetched commit records,
from the URLs its .gitmodules gives, after every clone, switch, checkout
and pull, so a pinned commit builds against its own submodules, and one
moved by hand is put back; with no .gitmodules, git isn't asked.
- Each external clone's fetch and build hold a file lock: parallel builds
pulled into one clone at once ("cannot lock ref").
- mingw:
- -stdlib=libstdc++ always;
- the std module's source is found through libstdc++.modules.json in the
mingw toolchain's own library directories (the generic lib/ holds the
host's Linux one), asking the compiler once a run;
- a toolchain that isn't there is a build error saying where it looked,
not std::terminate;
- CRAFTER_MINGW_DIR names the prefix, the directory holding
x86_64-w64-mingw32/ and lib/gcc/;
- inside the Flatpak the host's /usr mingw is used.
- RunScript's exit-status check reads as the one range it is.
- Tests: ConcurrentExternalFetch, ExternalArtifactTime, GitSubmodules,
LinkFlagsChange, MingwMissing, ShaderIncludeChange; ShaderCompile and
ToolchainResolve extended.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
959c65dbb2
commit
653dd0bb9b
26 changed files with 1075 additions and 109 deletions
95
tests/ConcurrentExternalFetch/main.cpp
Normal file
95
tests/ConcurrentExternalFetch/main.cpp
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
// 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: the tests `crafter-build test --jobs=N` builds side by side each
|
||||
// declare the same ExternalDependency, 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.
|
||||
//
|
||||
// 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. Everything is local;
|
||||
// XDG_CACHE_HOME keeps the clone 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));
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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([&] {
|
||||
try {
|
||||
(void)GitFetch(source);
|
||||
} catch (const std::exception& e) {
|
||||
std::lock_guard lock(errorsMutex);
|
||||
errors.push_back(e.what());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Check(errors.empty(), std::format("{} fetches at once, {} rounds: every one succeeds ({} failed{}{})", threads, rounds, errors.size(), errors.empty() ? "" : ", the first: ", errors.empty() ? "" : errors.front()));
|
||||
std::ifstream round(clone / "round.txt");
|
||||
std::uint32_t last = 0;
|
||||
round >> last;
|
||||
Check(last == rounds, std::format("and the clone ends at the last round ({})", last));
|
||||
|
||||
std::error_code ec;
|
||||
fs::remove_all(scratch, ec);
|
||||
if (failures > 0) {
|
||||
std::println(std::cerr, "{} assertions failed", failures);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
89
tests/ExternalArtifactTime/main.cpp
Normal file
89
tests/ExternalArtifactTime/main.cpp
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
// 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;
|
||||
|
||||
// A CMake dependency's newest library decides when its consumers are stale
|
||||
// (BuildExternal's latestArtifact). Only files directly in the CMake build
|
||||
// directory named *.a or *.so were looked at, so msquic's
|
||||
// bin/Release/libmsquic.so.2, in one of the dependency's libDirs and with a
|
||||
// version after the .so, never counted: a rebuilt msquic left everything
|
||||
// built against the old one in place.
|
||||
//
|
||||
// The dependency here is a local repository whose CMake project puts a
|
||||
// versioned shared library in bin/Release, declared as a libDir. Everything
|
||||
// is local; XDG_CACHE_HOME keeps the clone 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 Write(const fs::path& file, std::string_view text) {
|
||||
std::ofstream(file, std::ios::binary | std::ios::trunc) << text;
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
const fs::path scratch = fs::temp_directory_path() / "crafter-build-external-artifact-time";
|
||||
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 / "versioned";
|
||||
fs::create_directories(repo);
|
||||
Write(repo / "lib.c", "int Versioned(void) { return 2; }\n");
|
||||
Write(repo / "CMakeLists.txt",
|
||||
"cmake_minimum_required(VERSION 3.16)\n"
|
||||
"project(versioned C)\n"
|
||||
"add_library(versioned SHARED lib.c)\n"
|
||||
"set_target_properties(versioned PROPERTIES VERSION 2.0.0 SOVERSION 2\n"
|
||||
" LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin/Release)\n");
|
||||
Git(repo, "init -q -b main");
|
||||
Git(repo, "add .");
|
||||
Git(repo, "commit -q -m versioned");
|
||||
|
||||
ExternalDependency dep;
|
||||
dep.name = "versioned";
|
||||
dep.source.url = std::format("file://{}", repo.string());
|
||||
dep.builder = ExternalBuilder::CMake;
|
||||
dep.libDirs = { "bin/Release" };
|
||||
dep.libs = { "versioned" };
|
||||
std::atomic<bool> cancelled = false;
|
||||
const ExternalBuildResult result = BuildExternal(dep, HostTarget(), cancelled);
|
||||
Check(result.error.empty(), std::format("the dependency builds: {}", result.error));
|
||||
|
||||
const fs::path library = ExternalCloneDir(dep, HostTarget()) / "build" / "bin" / "Release" / "libversioned.so.2.0.0";
|
||||
Check(fs::exists(library), std::format("the library is where its CMake project puts it: {}", library.string()));
|
||||
if (fs::exists(library)) {
|
||||
Check(result.latestArtifact == fs::last_write_time(library), "its newest artifact is the versioned library in the libDir");
|
||||
}
|
||||
|
||||
std::error_code ec;
|
||||
fs::remove_all(scratch, ec);
|
||||
if (failures > 0) {
|
||||
std::println(std::cerr, "{} assertions failed", failures);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
128
tests/GitSubmodules/main.cpp
Normal file
128
tests/GitSubmodules/main.cpp
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
// 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;
|
||||
|
||||
// A fetched dependency's submodules have to match the revision it was fetched
|
||||
// at. They used to come from the remote's default branch: a fresh clone was
|
||||
// `git clone --recursive`, which checks out the default branch's submodules,
|
||||
// and the `switch` or `checkout` to the requested branch or commit that came
|
||||
// after it left them where they were. A branch that was pulled forward kept
|
||||
// the submodules of its old tip as well. So pinning msquic to a release built
|
||||
// that release against the main branch's quictls.
|
||||
//
|
||||
// The test makes a repository whose submodule moves between two of its
|
||||
// commits, then fetches it four ways through GitFetch:
|
||||
// - pinned to the older commit;
|
||||
// - by a branch at the older commit;
|
||||
// - by a branch that is then moved to the newer commit and fetched again;
|
||||
// - pinned again after the cached clone's submodule was checked out
|
||||
// elsewhere, as an older crafter-build left it.
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
// Runs git in `dir`; the output, or an exception naming the command.
|
||||
std::string 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));
|
||||
while (!result.output.empty() && (result.output.back() == '\n' || result.output.back() == '\r')) result.output.pop_back();
|
||||
return result.output;
|
||||
}
|
||||
|
||||
void Write(const fs::path& file, std::string_view text) {
|
||||
std::ofstream(file, std::ios::binary | std::ios::trunc) << text;
|
||||
}
|
||||
|
||||
std::string Read(const fs::path& file) {
|
||||
std::ifstream stream(file, std::ios::binary);
|
||||
return std::string(std::istreambuf_iterator<char>(stream), std::istreambuf_iterator<char>());
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
const fs::path scratch = fs::temp_directory_path() / "crafter-build-git-submodules";
|
||||
fs::remove_all(scratch);
|
||||
fs::create_directories(scratch);
|
||||
setenv("XDG_CACHE_HOME", (scratch / "cache").c_str(), 1);
|
||||
// A known git: no user or system configuration, an identity for the
|
||||
// commits, and file:// allowed for submodules (git refuses it by default),
|
||||
// also for the git that GitFetch runs.
|
||||
setenv("GIT_CONFIG_NOSYSTEM", "1", 1);
|
||||
setenv("GIT_CONFIG_GLOBAL", "/dev/null", 1);
|
||||
setenv("GIT_CONFIG_COUNT", "1", 1);
|
||||
setenv("GIT_CONFIG_KEY_0", "protocol.file.allow", 1);
|
||||
setenv("GIT_CONFIG_VALUE_0", "always", 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);
|
||||
|
||||
// The submodule: "one", then "two".
|
||||
const fs::path sub = scratch / "sub";
|
||||
fs::create_directories(sub);
|
||||
Git(sub, "init -q -b main");
|
||||
Write(sub / "value.txt", "one");
|
||||
Git(sub, "add value.txt");
|
||||
Git(sub, "commit -q -m one");
|
||||
const std::string one = Git(sub, "rev-parse HEAD");
|
||||
Write(sub / "value.txt", "two");
|
||||
Git(sub, "commit -q -am two");
|
||||
const std::string two = Git(sub, "rev-parse HEAD");
|
||||
|
||||
// The superproject: `older` has the submodule at "one", main at "two".
|
||||
const fs::path super = scratch / "super";
|
||||
fs::create_directories(super);
|
||||
Git(super, "init -q -b main");
|
||||
Git(super, std::format("submodule --quiet add {} lib", ShellQuote(std::format("file://{}", sub.string()))));
|
||||
Git(super / "lib", std::format("checkout -q {}", one));
|
||||
Git(super, "add lib");
|
||||
Git(super, "commit -q -m older");
|
||||
const std::string older = Git(super, "rev-parse HEAD");
|
||||
Git(super, "branch older");
|
||||
Git(super, "branch track");
|
||||
Git(super / "lib", std::format("checkout -q {}", two));
|
||||
Git(super, "add lib");
|
||||
Git(super, "commit -q -m newer");
|
||||
const std::string url = std::format("file://{}", super.string());
|
||||
|
||||
// ── Pinned to the older commit ───────────────────────────────────────────
|
||||
const fs::path pinned = GitFetch({ .url = url, .commit = older });
|
||||
Check(Read(pinned / "lib" / "value.txt") == "one", std::format("a commit pin gets that commit's submodule (lib says {})", Read(pinned / "lib" / "value.txt")));
|
||||
|
||||
// ── By a branch at the older commit ──────────────────────────────────────
|
||||
const fs::path branch = GitFetch({ .url = url, .branch = "older" });
|
||||
Check(Read(branch / "lib" / "value.txt") == "one", std::format("a branch gets its own tip's submodule (lib says {})", Read(branch / "lib" / "value.txt")));
|
||||
|
||||
// ── A tracked branch moving forward ──────────────────────────────────────
|
||||
const fs::path tracked = GitFetch({ .url = url, .branch = "track" });
|
||||
Check(Read(tracked / "lib" / "value.txt") == "one", "a tracked branch starts with its tip's submodule");
|
||||
Git(super, "branch -f track main");
|
||||
const fs::path pulled = GitFetch({ .url = url, .branch = "track" });
|
||||
Check(pulled == tracked && Read(pulled / "lib" / "value.txt") == "two", std::format("pulling it forward moves the submodule along (lib says {})", Read(pulled / "lib" / "value.txt")));
|
||||
|
||||
// ── A cached clone with its submodule out of step ────────────────────────
|
||||
Git(pinned / "lib", std::format("checkout -q {}", two));
|
||||
const fs::path repaired = GitFetch({ .url = url, .commit = older });
|
||||
Check(repaired == pinned && Read(repaired / "lib" / "value.txt") == "one", std::format("a cached clone at the pin gets its submodule put back (lib says {})", Read(repaired / "lib" / "value.txt")));
|
||||
|
||||
std::error_code ec;
|
||||
fs::remove_all(scratch, ec);
|
||||
if (failures > 0) {
|
||||
std::println(std::cerr, "{} assertions failed", failures);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
6
tests/LinkFlagsChange/fixture/main.cpp
Normal file
6
tests/LinkFlagsChange/fixture/main.cpp
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
int main() {
|
||||
return 0;
|
||||
}
|
||||
119
tests/LinkFlagsChange/main.cpp
Normal file
119
tests/LinkFlagsChange/main.cpp
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
// 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;
|
||||
|
||||
// A change to what an executable is linked with has to relink it, even when
|
||||
// no object changed. The relink check compared modification times only, so a
|
||||
// link flag added in project.cpp, or a dependency's library moving (an
|
||||
// ExternalDependency whose options changed is fetched and built into a new
|
||||
// cache directory, and its -L and rpath follow), left the old binary in
|
||||
// place: Crafter.Network's tests kept loading the msquic they were first
|
||||
// linked against.
|
||||
//
|
||||
// The test adds an rpath to an executable's link flags, takes it away again,
|
||||
// and then builds once more with nothing changed, which must not relink.
|
||||
// The flags come out of sets the dependency threads fill as they finish, so
|
||||
// the recorded command lists them sorted: in the order they happened to land
|
||||
// in, an unchanged build could look like a changed one.
|
||||
|
||||
namespace {
|
||||
std::int32_t failures = 0;
|
||||
|
||||
void Check(bool cond, std::string_view msg) {
|
||||
if (!cond) {
|
||||
std::println(std::cerr, "FAIL: {}", msg);
|
||||
++failures;
|
||||
}
|
||||
}
|
||||
|
||||
constexpr std::string_view marker = "/crafter-build-link-flags-change-marker";
|
||||
|
||||
BuildResult BuildOnce(const fs::path& project, std::vector<std::string> linkFlags) {
|
||||
Configuration app;
|
||||
app.path = project;
|
||||
app.name = "linked";
|
||||
app.outputName = "linked";
|
||||
app.target = HostTarget();
|
||||
app.type = ConfigurationType::Executable;
|
||||
app.linkFlags = std::move(linkFlags);
|
||||
std::array<fs::path, 0> ifaces = {};
|
||||
std::array<fs::path, 1> impls = { "main" };
|
||||
app.GetInterfacesAndImplementations(ifaces, impls);
|
||||
std::unordered_map<fs::path, std::shared_future<BuildResult>> depResults;
|
||||
std::mutex depMutex;
|
||||
BuildResult result = Build(app, depResults, depMutex);
|
||||
return result;
|
||||
}
|
||||
|
||||
Configuration Plain(const fs::path& project) {
|
||||
Configuration app;
|
||||
app.path = project;
|
||||
app.name = "linked";
|
||||
app.outputName = "linked";
|
||||
app.target = HostTarget();
|
||||
app.type = ConfigurationType::Executable;
|
||||
return app;
|
||||
}
|
||||
|
||||
fs::path Binary(const fs::path& project) {
|
||||
return Plain(project).BinDir() / "linked";
|
||||
}
|
||||
|
||||
bool HasMarker(const fs::path& binary) {
|
||||
std::ifstream stream(binary, std::ios::binary);
|
||||
const std::string bytes((std::istreambuf_iterator<char>(stream)), std::istreambuf_iterator<char>());
|
||||
return bytes.find(marker) != std::string::npos;
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
const fs::path project = fs::temp_directory_path() / "crafter-build-link-flags-change";
|
||||
fs::remove_all(project);
|
||||
fs::copy(fs::current_path() / "tests" / "LinkFlagsChange" / "fixture", project, fs::copy_options::recursive);
|
||||
const std::string rpath = std::format("-Wl,-rpath,{}", marker);
|
||||
|
||||
const BuildResult first = BuildOnce(project, {});
|
||||
Check(first.result.empty(), std::format("the plain build succeeds: {}", first.result));
|
||||
const fs::path binary = Binary(project);
|
||||
Check(fs::exists(binary) && !HasMarker(binary), "the plain binary has no rpath to the marker");
|
||||
|
||||
const BuildResult added = BuildOnce(project, { rpath });
|
||||
Check(added.result.empty(), std::format("the build with the rpath succeeds: {}", added.result));
|
||||
Check(HasMarker(binary), "adding a link flag relinks the binary");
|
||||
|
||||
const BuildResult removed = BuildOnce(project, {});
|
||||
Check(removed.result.empty(), std::format("the build without it succeeds: {}", removed.result));
|
||||
Check(!HasMarker(binary), "taking it away relinks it again");
|
||||
|
||||
const fs::file_time_type linked = fs::last_write_time(binary);
|
||||
const BuildResult again = BuildOnce(project, {});
|
||||
Check(again.result.empty() && fs::last_write_time(binary) == linked, "an unchanged build doesn't relink");
|
||||
|
||||
const std::vector<std::string> flags = { "-Wl,-z,now", "-Wl,-O1", "-Wl,--as-needed", "-Wl,-z,relro", "-Wl,--build-id", "-Wl,--hash-style=gnu" };
|
||||
const BuildResult many = BuildOnce(project, flags);
|
||||
Check(many.result.empty(), std::format("the build with six flags succeeds: {}", many.result));
|
||||
std::ifstream recorded(Plain(project).BuildDir() / "linked.link", std::ios::binary);
|
||||
const std::string record((std::istreambuf_iterator<char>(recorded)), std::istreambuf_iterator<char>());
|
||||
std::vector<std::size_t> at;
|
||||
for (const std::string& flag : flags) at.push_back(record.find(std::format(" {}", flag)));
|
||||
std::vector<std::string> sorted = flags;
|
||||
std::ranges::sort(sorted);
|
||||
bool inOrder = !std::ranges::contains(at, std::string::npos);
|
||||
for (std::size_t k = 0; inOrder && k + 1 < sorted.size(); ++k) {
|
||||
const auto index = [&](const std::string& flag) { return at[static_cast<std::size_t>(std::ranges::find(flags, flag) - flags.begin())]; };
|
||||
inOrder = index(sorted[k]) < index(sorted[k + 1]);
|
||||
}
|
||||
Check(inOrder, std::format("the link record lists the flags sorted: {}", record));
|
||||
|
||||
std::error_code ec;
|
||||
fs::remove_all(project, ec);
|
||||
if (failures > 0) {
|
||||
std::println(std::cerr, "{} assertions failed", failures);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
9
tests/MingwMissing/fixture/main.cpp
Normal file
9
tests/MingwMissing/fixture/main.cpp
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
import std;
|
||||
|
||||
int main() {
|
||||
std::println("hello from crafter-build");
|
||||
return 0;
|
||||
}
|
||||
97
tests/MingwMissing/main.cpp
Normal file
97
tests/MingwMissing/main.cpp
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
// 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;
|
||||
|
||||
// Building for x86_64-w64-mingw32 without a usable mingw-w64 libstdc++ has to
|
||||
// fail the build with a message that says what is missing. It used to abort
|
||||
// the whole process with a bare "libc++abi: terminating": the std module's
|
||||
// source was found by listing <mingw sysroot>/include/c++ with a throwing
|
||||
// directory_iterator, the exception unwound through Build() while its worker
|
||||
// threads were still joinable, and ~thread called std::terminate. That is
|
||||
// what every mingw build inside the Flatpak did, as the sandbox has no
|
||||
// /usr/x86_64-w64-mingw32.
|
||||
//
|
||||
// The std module's source is now found through libstdc++.modules.json in the
|
||||
// mingw toolchain's library directories. CRAFTER_MINGW_DIR points the
|
||||
// toolchain at a scratch prefix, which the test takes through four states:
|
||||
// - missing;
|
||||
// - with only a manifest in the prefix's generic lib/, where the host's
|
||||
// Linux libstdc++ keeps one: not the mingw library, so still missing;
|
||||
// - with a mingw manifest that names no std module;
|
||||
// - with one whose std module source isn't there.
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
BuildResult BuildOnce(const fs::path& project) {
|
||||
Configuration app;
|
||||
app.path = project;
|
||||
app.name = "hello";
|
||||
app.outputName = "hello";
|
||||
app.target = "x86_64-w64-mingw32";
|
||||
app.type = ConfigurationType::Executable;
|
||||
std::array<fs::path, 0> ifaces = {};
|
||||
std::array<fs::path, 1> impls = { "main" };
|
||||
app.GetInterfacesAndImplementations(ifaces, impls);
|
||||
std::unordered_map<fs::path, std::shared_future<BuildResult>> depResults;
|
||||
std::mutex depMutex;
|
||||
return Build(app, depResults, depMutex);
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
const fs::path scratch = fs::temp_directory_path() / "crafter-build-mingw-missing";
|
||||
fs::remove_all(scratch);
|
||||
fs::create_directories(scratch);
|
||||
const fs::path project = scratch / "project";
|
||||
fs::copy(fs::current_path() / "tests" / "MingwMissing" / "fixture", project, fs::copy_options::recursive);
|
||||
const fs::path sysroot = scratch / "mingw";
|
||||
// Before anything resolves the toolchain, which reads both once. The std
|
||||
// module cache goes to the scratch directory too.
|
||||
setenv("CRAFTER_MINGW_DIR", sysroot.c_str(), 1);
|
||||
setenv("XDG_CACHE_HOME", (scratch / "cache").c_str(), 1);
|
||||
|
||||
const BuildResult missing = BuildOnce(project);
|
||||
Check(!missing.result.empty(), "a missing mingw prefix fails the build");
|
||||
Check(missing.result.contains("mingw") && missing.result.contains(sysroot.string()) && missing.result.contains("CRAFTER_MINGW_DIR"), std::format("and says where it looked and how to point elsewhere: {}", missing.result));
|
||||
|
||||
auto write = [](const fs::path& file, std::string_view text) {
|
||||
fs::create_directories(file.parent_path());
|
||||
std::ofstream(file, std::ios::binary | std::ios::trunc) << text;
|
||||
};
|
||||
write(sysroot / "lib" / "libstdc++.modules.json", R"({"version": 1, "modules": [{"logical-name": "std", "source-path": "../include/c++/16/bits/std.cc", "is-std-library": true}]})");
|
||||
write(sysroot / "include" / "c++" / "16" / "bits" / "std.cc", "// the host's Linux std module\n");
|
||||
const BuildResult linux = BuildOnce(project);
|
||||
Check(linux.result.contains("no mingw-w64 libstdc++"), std::format("a manifest outside the mingw toolchain's own directories isn't taken for it: {}", linux.result));
|
||||
|
||||
const fs::path manifest = sysroot / "x86_64-w64-mingw32" / "lib" / "libstdc++.modules.json";
|
||||
write(manifest, R"({"version": 1, "modules": [{"logical-name": "std.compat", "source-path": "../include/c++/15.1.0/bits/std.compat.cc"}]})");
|
||||
const BuildResult noStd = BuildOnce(project);
|
||||
Check(noStd.result.contains(manifest.string()) && noStd.result.contains("no std module"), std::format("a manifest without the std module fails the build, naming the manifest: {}", noStd.result));
|
||||
|
||||
write(manifest, R"({"version": 1, "modules": [{"source-path": "../include/c++/15.1.0/bits/std.cc", "logical-name": "std"}]})");
|
||||
const BuildResult noSource = BuildOnce(project);
|
||||
Check(noSource.result.contains((sysroot / "x86_64-w64-mingw32" / "include" / "c++" / "15.1.0" / "bits" / "std.cc").string()) && noSource.result.contains("missing"),
|
||||
std::format("a std module source that isn't there fails the build, naming it: {}", noSource.result));
|
||||
|
||||
std::error_code ec;
|
||||
fs::remove_all(scratch, ec);
|
||||
if (failures > 0) {
|
||||
std::println(std::cerr, "{} assertions failed", failures);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -27,7 +27,7 @@ int main() {
|
|||
Shader shader(fs::path(src), "main", ShaderType::Fragment);
|
||||
|
||||
std::array<fs::path, 0> includeDirs = {};
|
||||
std::string err = shader.Compile(outDir, includeDirs);
|
||||
std::string err = shader.Compile(outDir, outDir, includeDirs);
|
||||
if (!err.empty()) {
|
||||
std::println(std::cerr, "shader compile failed: {}", err);
|
||||
return 1;
|
||||
|
|
@ -51,10 +51,27 @@ int main() {
|
|||
}
|
||||
|
||||
// Compiling again should be a no-op since the source isn't newer.
|
||||
if (!shader.Check(outDir)) {
|
||||
if (!shader.Check(outDir, outDir, includeDirs)) {
|
||||
std::println(std::cerr, "Shader::Check returned false on an up-to-date spv");
|
||||
return 1;
|
||||
}
|
||||
// Searched elsewhere, an #include could find another file: stale.
|
||||
const std::array<fs::path, 1> elsewhere = { outDir / "elsewhere" };
|
||||
if (shader.Check(outDir, outDir, elsewhere)) {
|
||||
std::println(std::cerr, "Shader::Check called an spv up to date for other include directories");
|
||||
return 1;
|
||||
}
|
||||
// So could a source of the same name moved to another directory, which
|
||||
// its #includes are searched from first.
|
||||
const fs::path movedDir = outDir / "moved";
|
||||
fs::create_directories(movedDir);
|
||||
fs::copy_file(src, movedDir / src.filename());
|
||||
fs::last_write_time(movedDir / src.filename(), fs::last_write_time(src));
|
||||
Shader moved(movedDir / src.filename(), "main", ShaderType::Fragment);
|
||||
if (moved.Check(outDir, outDir, includeDirs)) {
|
||||
std::println(std::cerr, "Shader::Check called an spv up to date for a source of the same name elsewhere");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// One source registered twice: the second entry carries defines and a
|
||||
// variant name. It must land in its own file instead of overwriting the
|
||||
|
|
@ -77,16 +94,16 @@ int main() {
|
|||
// exists, the variant is still due. A Check() that ignored the
|
||||
// variant would call it up to date here, and Build() would then
|
||||
// never compile a variant added to an already built project.
|
||||
if (each == &red && red.Check(outDir)) {
|
||||
if (each == &red && red.Check(outDir, outDir, includeDirs)) {
|
||||
std::println(std::cerr, "Shader::Check called the variant up to date before it was ever compiled");
|
||||
return 1;
|
||||
}
|
||||
std::string variantErr = each->Compile(outDir, includeDirs);
|
||||
std::string variantErr = each->Compile(outDir, outDir, includeDirs);
|
||||
if (!variantErr.empty()) {
|
||||
std::println(std::cerr, "variant shader compile failed: {}", variantErr);
|
||||
return 1;
|
||||
}
|
||||
if (!each->Check(outDir)) {
|
||||
if (!each->Check(outDir, outDir, includeDirs)) {
|
||||
std::println(std::cerr, "Shader::Check returned false for {}", each->OutputName().string());
|
||||
return 1;
|
||||
}
|
||||
|
|
@ -99,14 +116,14 @@ int main() {
|
|||
Shader malformed(fs::path(variantSrc), "main", ShaderType::Fragment);
|
||||
malformed.defines = {bad};
|
||||
malformed.variant = "malformed";
|
||||
if (!malformed.Compile(outDir, includeDirs).contains("invalid shader define")) {
|
||||
if (!malformed.Compile(outDir, outDir, includeDirs).contains("invalid shader define")) {
|
||||
std::println(std::cerr, "a malformed define was not rejected by name");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
Shader nested(fs::path(variantSrc), "main", ShaderType::Fragment);
|
||||
nested.variant = "sub/red";
|
||||
if (!nested.Compile(outDir, includeDirs).contains("path separator")) {
|
||||
if (!nested.Compile(outDir, outDir, includeDirs).contains("path separator")) {
|
||||
std::println(std::cerr, "a variant with a path separator was not rejected by name");
|
||||
return 1;
|
||||
}
|
||||
|
|
|
|||
1
tests/ShaderIncludeChange/fixture/include/palette.glsl
Normal file
1
tests/ShaderIncludeChange/fixture/include/palette.glsl
Normal file
|
|
@ -0,0 +1 @@
|
|||
const vec4 kTint = vec4(0.0, 1.0, 0.0, 1.0);
|
||||
16
tests/ShaderIncludeChange/fixture/lit.frag
Normal file
16
tests/ShaderIncludeChange/fixture/lit.frag
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
#version 450
|
||||
#extension GL_GOOGLE_include_directive : require
|
||||
|
||||
// Built by the ShaderIncludeChange test, which edits palette.glsl and this
|
||||
// entry's defines between builds.
|
||||
#include "palette.glsl"
|
||||
|
||||
layout(location = 0) out vec4 outColor;
|
||||
|
||||
void main() {
|
||||
#ifdef LIT_DIM
|
||||
outColor = kTint * 0.5;
|
||||
#else
|
||||
outColor = kTint;
|
||||
#endif
|
||||
}
|
||||
6
tests/ShaderIncludeChange/fixture/main.cpp
Normal file
6
tests/ShaderIncludeChange/fixture/main.cpp
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
int main() {
|
||||
return 0;
|
||||
}
|
||||
8
tests/ShaderIncludeChange/fixture/plain.frag
Normal file
8
tests/ShaderIncludeChange/fixture/plain.frag
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
#version 450
|
||||
|
||||
// Includes nothing: the ShaderIncludeChange test checks it is left alone.
|
||||
layout(location = 0) out vec4 outColor;
|
||||
|
||||
void main() {
|
||||
outColor = vec4(0.0, 0.0, 1.0, 1.0);
|
||||
}
|
||||
126
tests/ShaderIncludeChange/main.cpp
Normal file
126
tests/ShaderIncludeChange/main.cpp
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
// 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;
|
||||
|
||||
// A shader's output is stale when anything that went into it changed, not
|
||||
// only its own source. Check() used to compare the .spv against the source's
|
||||
// mtime alone, so:
|
||||
// - editing a file the shader #includes left the .spv built against the old
|
||||
// contents, the GLSL twin of IncrementalHeaderChange;
|
||||
// - changing an entry's defines without touching the source did too.
|
||||
// Each pass changes one thing and checks both directions: the shader it
|
||||
// reaches is recompiled, to new contents, and the shader that includes
|
||||
// nothing is left alone. A build with nothing changed compiles nothing.
|
||||
namespace {
|
||||
std::int32_t failures = 0;
|
||||
|
||||
void Check(bool cond, std::string_view msg) {
|
||||
if (!cond) {
|
||||
std::println(std::cerr, "FAIL: {}", msg);
|
||||
++failures;
|
||||
}
|
||||
}
|
||||
|
||||
// The fixture is edited between passes, so work on a copy outside the repo.
|
||||
fs::path StageFixture() {
|
||||
fs::path source = fs::current_path() / "tests" / "ShaderIncludeChange" / "fixture";
|
||||
fs::path staged = fs::temp_directory_path() / "crafter-build-shader-include-change";
|
||||
fs::remove_all(staged);
|
||||
fs::copy(source, staged, fs::copy_options::recursive);
|
||||
return staged;
|
||||
}
|
||||
|
||||
Configuration MakeApp(const fs::path& staged) {
|
||||
Configuration app;
|
||||
app.path = staged;
|
||||
app.name = "lit";
|
||||
app.outputName = "lit";
|
||||
app.target = HostTarget();
|
||||
app.type = ConfigurationType::Executable;
|
||||
std::array<fs::path, 0> ifaces = {};
|
||||
std::array<fs::path, 1> impls = { "main" };
|
||||
app.GetInterfacesAndImplementations(ifaces, impls);
|
||||
app.shaders.emplace_back(staged / "lit.frag", std::string("main"), ShaderType::Fragment);
|
||||
app.shaders.emplace_back(staged / "plain.frag", std::string("main"), ShaderType::Fragment);
|
||||
// Puts include/ on the shaders' #include path.
|
||||
app.buildFiles = { staged / "include" / "palette.glsl" };
|
||||
return app;
|
||||
}
|
||||
|
||||
bool BuildOk(Configuration& app, std::string_view label) {
|
||||
// A fresh depResults per pass: the map memoizes a Configuration's
|
||||
// build for the duration of one pass.
|
||||
std::unordered_map<fs::path, std::shared_future<BuildResult>> depResults;
|
||||
std::mutex depMutex;
|
||||
BuildResult r = Build(app, depResults, depMutex);
|
||||
Check(r.result.empty(), std::format("{} builds: {}", label, r.result));
|
||||
return r.result.empty();
|
||||
}
|
||||
|
||||
std::string ReadAll(const fs::path& file) {
|
||||
std::ifstream stream(file, std::ios::binary);
|
||||
return std::string(std::istreambuf_iterator<char>(stream), std::istreambuf_iterator<char>());
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
const fs::path staged = StageFixture();
|
||||
Configuration app = MakeApp(staged);
|
||||
const fs::path lit = app.BinDir() / "lit.spv";
|
||||
const fs::path plain = app.BinDir() / "plain.spv";
|
||||
const fs::path palette = staged / "include" / "palette.glsl";
|
||||
|
||||
// Pass 1: everything from scratch.
|
||||
if (!BuildOk(app, "the first pass")) return 1;
|
||||
Check(fs::exists(lit) && fs::exists(plain), "both shaders are in the bin dir");
|
||||
const fs::file_time_type plainBuilt = fs::last_write_time(plain);
|
||||
const std::string green = ReadAll(lit);
|
||||
|
||||
// Pass 2: nothing changed.
|
||||
fs::file_time_type litBuilt = fs::last_write_time(lit);
|
||||
BuildOk(app, "an unchanged project");
|
||||
Check(fs::last_write_time(lit) == litBuilt && fs::last_write_time(plain) == plainBuilt, "with nothing changed, no shader is recompiled");
|
||||
|
||||
// Pass 3: the included file changes. Dated just after the output it must
|
||||
// outdate, so the rebuilt output is newer than it again.
|
||||
{
|
||||
std::ofstream out(palette, std::ios::trunc);
|
||||
out << "const vec4 kTint = vec4(1.0, 0.0, 0.0, 1.0);\n";
|
||||
}
|
||||
fs::last_write_time(palette, litBuilt + std::chrono::milliseconds(1));
|
||||
BuildOk(app, "an edited include");
|
||||
const std::string red = ReadAll(lit);
|
||||
Check(fs::last_write_time(lit) > litBuilt && red != green, "editing an included file recompiles the shader that includes it, to new contents");
|
||||
Check(fs::last_write_time(plain) == plainBuilt, "and leaves the shader that includes nothing alone");
|
||||
|
||||
// Pass 4: the entry's defines change, no file does.
|
||||
app.shaders[0].defines = { "LIT_DIM" };
|
||||
litBuilt = fs::last_write_time(lit);
|
||||
BuildOk(app, "a new define");
|
||||
const std::string dim = ReadAll(lit);
|
||||
Check(fs::last_write_time(lit) > litBuilt && dim != red, "a define added to the entry recompiles it, taking the #ifdef branch");
|
||||
Check(fs::last_write_time(plain) == plainBuilt, "and leaves the other entry alone");
|
||||
|
||||
// Pass 5: and back.
|
||||
app.shaders[0].defines = {};
|
||||
BuildOk(app, "the define taken out");
|
||||
Check(ReadAll(lit) == red, "taking the define out again recompiles it as it was");
|
||||
|
||||
// Pass 6: nothing changed again; what the passes above recorded holds.
|
||||
litBuilt = fs::last_write_time(lit);
|
||||
BuildOk(app, "an unchanged project, again");
|
||||
Check(fs::last_write_time(lit) == litBuilt && fs::last_write_time(plain) == plainBuilt, "with nothing changed since, no shader is recompiled");
|
||||
|
||||
std::error_code ec;
|
||||
fs::remove_all(staged, ec);
|
||||
|
||||
if (failures > 0) {
|
||||
std::println(std::cerr, "{} assertions failed", failures);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -61,7 +61,7 @@ int main() {
|
|||
Check(tc.libcxxVersion == LibcxxVersion("/usr/include/c++/v1"), "the system libc++ headers state the release of the system module source");
|
||||
Check(tc.stdModulesDir.empty(), "no crafter-build prefix, no shipped module sources");
|
||||
Check(tc.wasiSysroot == "/usr/share/wasi-sysroot", "the system wasi sysroot is the default");
|
||||
Check(tc.mingwSysroot == "/usr/x86_64-w64-mingw32" && !tc.mingwSysrootExplicit, "the default mingw dir is not passed as --sysroot");
|
||||
Check(tc.mingwSysroot.empty(), "outside a sandbox, clang finds mingw-w64 itself: no --sysroot");
|
||||
Check(tc.LibcxxCompileFlags().empty() && tc.LibcxxLinkFlags().empty(), "the PATH fallback adds no libc++ flags");
|
||||
}
|
||||
{
|
||||
|
|
@ -144,7 +144,21 @@ int main() {
|
|||
in.env["CRAFTER_MINGW_DIR"] = "/opt/mingw";
|
||||
Toolchain tc = ResolveToolchain(in);
|
||||
Check(tc.wasiSysroot == "/opt/wasi", "CRAFTER_BUILD_WASI_SYSROOT overrides the wasi sysroot");
|
||||
Check(tc.mingwSysroot == "/opt/mingw" && tc.mingwSysrootExplicit, "CRAFTER_MINGW_DIR overrides the mingw dir and is passed as --sysroot");
|
||||
Check(tc.mingwSysroot == "/opt/mingw", "CRAFTER_MINGW_DIR names the mingw prefix passed as --sysroot");
|
||||
}
|
||||
{
|
||||
// Sandboxed, the host's /usr stands in for the missing mingw-w64,
|
||||
// unless CRAFTER_MINGW_DIR says otherwise.
|
||||
const fs::path host = fs::temp_directory_path() / "crafter-build-toolchain-host";
|
||||
fs::remove_all(host);
|
||||
ToolchainInputs in;
|
||||
in.hostRoot = host;
|
||||
Check(ResolveToolchain(in).mingwSysroot.empty(), "a host without mingw-w64 gives no --sysroot");
|
||||
fs::create_directories(host / "usr" / "x86_64-w64-mingw32");
|
||||
Check(ResolveToolchain(in).mingwSysroot == host / "usr", "a host with mingw-w64 gives its /usr as the mingw prefix");
|
||||
in.env["CRAFTER_MINGW_DIR"] = "/opt/mingw";
|
||||
Check(ResolveToolchain(in).mingwSysroot == "/opt/mingw", "CRAFTER_MINGW_DIR wins over the host's");
|
||||
fs::remove_all(host);
|
||||
}
|
||||
{
|
||||
// The cache key separates toolchains that would build different
|
||||
|
|
|
|||
Loading…
Reference in a new issue