std.pcm was rebuilt when it was older than std.cppm, and project.so when it was older than the crafter-build executable. Both inputs are installed files, and an OSTree deployment (the Flatpak) gives every installed file mtime 0: std.cppm and the executable always looked older than the cache, so a clang bump would never rebuild std.pcm and a crafter-build update would never recompile project.so. clang crashes on a PCM from another compiler instead of reporting it, so the failure would have been a backtrace on the user's first build after an update. Each artifact now has a one-line stamp beside it. std.pcm's covers the precompile command and the module source's bytes, which also catches a libc++ update under the same clang. project.so's covers the crafter-build version, the toolchain key, the module source stamp and the compile command; project.cpp's and the pre-dependency artifacts' mtimes remain inputs, since those live next to it with real timestamps. The module source stamp stands in for the executable: project.so resolves Crafter:: symbols from the running binary, so only declarations matter, and an implementation-only rebuild of crafter-build no longer recompiles it. The std.pcm stamp folds into the module cache stamp, so a rebuilt std.pcm rebuilds the PCMs that import it. The cache directory carries the toolchain key (<target>-<march>-<clang version>-<hash>), so a compiler bump starts cold instead of handing the new clang PCMs it will crash on. LoadProject and Build() now derive the directory from the same StdPcmDir. Old <target>-<march> directories are left behind; `clean` never touched the cache and still does not. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
873 lines
39 KiB
C++
873 lines
39 KiB
C++
// SPDX-License-Identifier: LGPL-3.0-only
|
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
|
|
|
module;
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <limits.h>
|
|
#include <unistd.h>
|
|
#include <dlfcn.h>
|
|
#include <sys/wait.h>
|
|
#include <sys/file.h>
|
|
#include <fcntl.h>
|
|
#include <errno.h>
|
|
module Crafter.Build:Platform_impl;
|
|
import std;
|
|
import :Platform;
|
|
import :Toolchain;
|
|
import :Clang;
|
|
import :Interface;
|
|
import :External;
|
|
import :Progress;
|
|
namespace fs = std::filesystem;
|
|
using namespace Crafter;
|
|
|
|
namespace {
|
|
// Exclusive advisory lock over one PCM cache dir (`<cache>/<target>-<march>/`),
|
|
// held for the duration of a (re)build. Concurrent crafter-build invocations —
|
|
// and the worker threads inside a single invocation — that share a cache
|
|
// serialize their builds through it: the first builder to acquire the lock
|
|
// precompiles std.pcm / the Crafter.Build-*.pcm modules while everyone else
|
|
// blocks, then re-checks freshness and reuses the finished file instead of
|
|
// re-running clang and racing to overwrite the same path. Each builder must
|
|
// pass through the lock before it reaches the compile steps that *read* those
|
|
// PCMs, so a reader never observes a half-written file.
|
|
//
|
|
// The lock lives in the OS, keyed on a `.lock` file in the cache dir, and is
|
|
// released automatically when the descriptor/handle closes — including on
|
|
// process death — so an interrupted build can never leave a stale lock. The
|
|
// descriptor is close-on-exec / non-inheritable so spawned clang processes
|
|
// don't keep the lock alive past our own release.
|
|
class CacheLock {
|
|
std::int32_t fd_ = -1;
|
|
public:
|
|
explicit CacheLock(const fs::path& cacheDir) {
|
|
fd_ = ::open((cacheDir / ".lock").c_str(), O_RDWR | O_CREAT | O_CLOEXEC, 0644);
|
|
if (fd_ >= 0) {
|
|
while (::flock(fd_, LOCK_EX) != 0 && errno == EINTR) {}
|
|
}
|
|
}
|
|
~CacheLock() {
|
|
if (fd_ >= 0) {
|
|
::close(fd_); // closing the descriptor drops the flock
|
|
}
|
|
}
|
|
CacheLock(const CacheLock&) = delete;
|
|
CacheLock& operator=(const CacheLock&) = delete;
|
|
};
|
|
|
|
// The cached Crafter.Build PCMs are keyed by `<target>-<march>` alone, so
|
|
// every crafter-build on the machine writes to the same files regardless of
|
|
// which share/crafter-build its module sources came from. Freshness used to
|
|
// be a per-file mtime comparison, which cannot distinguish "this PCM is
|
|
// newer than my source" from "this PCM was built from *different* sources
|
|
// that happen to be newer" — so a second install or checkout silently
|
|
// compiled its project against the other's declarations. Same failure shape
|
|
// as issue #27: no error, a binary built against a layout nobody linked.
|
|
//
|
|
// A stamp over the bytes of every module source answers what the mtime
|
|
// can't. It covers the whole set rather than one file at a time because the
|
|
// PCMs import each other: a change to :Interface invalidates :Clang's PCM
|
|
// even though Crafter.Build-Clang.cppm is untouched.
|
|
std::string CrafterBuildSourceStamp(const fs::path& sourceDir, std::span<const std::string_view> moduleNames) {
|
|
std::string all;
|
|
auto append = [&all, &sourceDir](const fs::path& relative) {
|
|
std::ifstream in(sourceDir / relative, std::ios::binary);
|
|
std::ostringstream buffer;
|
|
buffer << in.rdbuf();
|
|
all += relative.string();
|
|
all += '\0';
|
|
all += buffer.str();
|
|
all += '\0';
|
|
};
|
|
for (std::string_view name : moduleNames) {
|
|
append(fs::path(std::format("{}.cppm", name)));
|
|
}
|
|
return std::format("{:016x}", std::hash<std::string>{}(all));
|
|
}
|
|
|
|
fs::path CacheStampPath(const fs::path& cacheDir) {
|
|
return cacheDir / "crafter-build-sources.stamp";
|
|
}
|
|
|
|
// A stamp is a one-line content hash kept next to the artifact it
|
|
// describes. Written last, after the artifact, so an interrupted build
|
|
// leaves a stamp that disagrees and the next run starts over.
|
|
std::string ReadStamp(const fs::path& file) {
|
|
std::ifstream in(file, std::ios::binary);
|
|
std::string stamp;
|
|
std::getline(in, stamp);
|
|
return stamp;
|
|
}
|
|
|
|
void WriteStamp(const fs::path& file, std::string_view stamp) {
|
|
std::ofstream(file, std::ios::binary | std::ios::trunc) << stamp << '\n';
|
|
}
|
|
|
|
fs::path StampPath(const fs::path& artifact) {
|
|
fs::path stamp = artifact;
|
|
stamp += ".stamp";
|
|
return stamp;
|
|
}
|
|
|
|
std::string HashStamp(std::string_view content) {
|
|
return std::format("{:016x}", std::hash<std::string_view>{}(content));
|
|
}
|
|
|
|
std::string FileBytes(const fs::path& file) {
|
|
std::ifstream in(file, std::ios::binary);
|
|
std::ostringstream buffer;
|
|
buffer << in.rdbuf();
|
|
return buffer.str();
|
|
}
|
|
}
|
|
|
|
fs::path Crafter::GetCrafterBuildHome() {
|
|
if (const char* envHome = std::getenv("CRAFTER_BUILD_HOME")) {
|
|
return fs::path(envHome);
|
|
}
|
|
fs::path hostExe = fs::read_symlink("/proc/self/exe");
|
|
|
|
// Walk up from the exe's directory looking for share/crafter-build with
|
|
// a known module source present as a sentinel. Handles:
|
|
// <prefix>/bin/crafter-build -> <prefix>/share/crafter-build (FHS install)
|
|
// <repo>/bin/crafter-build -> <repo>/share/crafter-build (build.sh)
|
|
// <repo>/bin/<arch>/crafter-build -> <repo>/share/crafter-build (self-host)
|
|
fs::path dir = hostExe.parent_path();
|
|
std::vector<fs::path> tried;
|
|
for (;;) {
|
|
fs::path candidate = dir / "share" / "crafter-build";
|
|
tried.push_back(candidate);
|
|
std::error_code ec;
|
|
if (fs::exists(candidate / "Crafter.Build.cppm", ec)) {
|
|
return candidate;
|
|
}
|
|
fs::path parent = dir.parent_path();
|
|
if (parent == dir) break;
|
|
dir = parent;
|
|
}
|
|
std::string msg = std::format("could not locate crafter-build runtime assets relative to {} (set CRAFTER_BUILD_HOME). Tried:", hostExe.string());
|
|
for (const auto& p : tried) {
|
|
msg += std::format("\n {}", p.string());
|
|
}
|
|
throw std::runtime_error(msg);
|
|
}
|
|
|
|
fs::file_time_type Crafter::NewestPrerequisite(const fs::path& depFile) {
|
|
std::ifstream in(depFile, std::ios::binary);
|
|
if (!in) return fs::file_time_type::max();
|
|
std::ostringstream buffer;
|
|
buffer << in.rdbuf();
|
|
const std::string text = std::move(buffer).str();
|
|
|
|
// Make-rule tokenizing, as clang writes it: `target: prereq prereq`, with a
|
|
// trailing '\' continuing the rule onto the next line (clang wraps at ~80
|
|
// columns, so any real depfile has several), '\ ' and '\#' escaping a
|
|
// character into a filename, and '$$' standing for a literal '$'. A
|
|
// backslash that is none of those is a Windows path separator and stays.
|
|
std::vector<std::string> tokens;
|
|
std::string token;
|
|
auto flush = [&tokens, &token]() {
|
|
if (!token.empty()) {
|
|
tokens.push_back(token);
|
|
token.clear();
|
|
}
|
|
};
|
|
for (std::size_t i = 0; i < text.size(); ++i) {
|
|
const char c = text[i];
|
|
const char next = i + 1 < text.size() ? text[i + 1] : '\0';
|
|
if (c == '\\' && (next == '\n' || next == '\r')) {
|
|
flush();
|
|
++i;
|
|
} else if (c == '\\' && (next == ' ' || next == '\t' || next == '#')) {
|
|
token += next;
|
|
++i;
|
|
} else if (c == '$' && next == '$') {
|
|
token += '$';
|
|
++i;
|
|
} else if (c == ' ' || c == '\t' || c == '\n' || c == '\r') {
|
|
flush();
|
|
} else {
|
|
token += c;
|
|
}
|
|
}
|
|
flush();
|
|
|
|
// A token ending in ':' names a rule target, not an input. The first one
|
|
// closes the target list (and a Windows `C:\build\x.o:` still ends in ':',
|
|
// where splitting on the first colon would not); a later one opens another
|
|
// rule, as `-MP` phony targets do, whose name is not a file to stat.
|
|
bool sawTarget = false;
|
|
fs::file_time_type newest = fs::file_time_type::min();
|
|
for (const std::string& prerequisite : tokens) {
|
|
if (prerequisite.ends_with(':')) {
|
|
sawTarget = true;
|
|
continue;
|
|
}
|
|
if (!sawTarget) continue;
|
|
std::error_code ec;
|
|
const fs::file_time_type stamp = fs::last_write_time(prerequisite, ec);
|
|
// A prerequisite that has since been deleted makes the recorded set
|
|
// unusable as evidence — the source that included it must have changed
|
|
// too, and rebuilding is what writes a depfile that no longer names it.
|
|
if (ec) return fs::file_time_type::max();
|
|
if (stamp > newest) newest = stamp;
|
|
}
|
|
// No rule at all: a truncated or empty depfile proves nothing.
|
|
if (!sawTarget) return fs::file_time_type::max();
|
|
return newest;
|
|
}
|
|
|
|
bool Crafter::MatchGlob(std::string_view glob, std::string_view name) {
|
|
std::size_t gi = 0;
|
|
std::size_t ni = 0;
|
|
std::size_t star = std::string_view::npos;
|
|
std::size_t mark = 0;
|
|
while (ni < name.size()) {
|
|
if (gi < glob.size() && (glob[gi] == '?' || glob[gi] == name[ni])) {
|
|
++gi; ++ni;
|
|
} else if (gi < glob.size() && glob[gi] == '*') {
|
|
star = gi++;
|
|
mark = ni;
|
|
} else if (star != std::string_view::npos) {
|
|
gi = star + 1;
|
|
ni = ++mark;
|
|
} else {
|
|
return false;
|
|
}
|
|
}
|
|
while (gi < glob.size() && glob[gi] == '*') ++gi;
|
|
return gi == glob.size();
|
|
}
|
|
|
|
bool Crafter::MatchAny(std::span<const std::string> globs, std::string_view name) {
|
|
if (globs.empty()) return true;
|
|
for (const auto& g : globs) {
|
|
if (MatchGlob(g, name)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// See the declaration in Crafter.Build-Platform.cppm for why this branches on
|
|
// the exit code rather than on whether the command said anything.
|
|
std::string Crafter::RunBuildCommand(std::string_view cmd) {
|
|
Progress::EchoCommand(cmd);
|
|
CommandResult r = RunCommandChecked(cmd);
|
|
|
|
if (r.exitCode == 0) {
|
|
// Warnings from a step that succeeded. Show them — until now they were
|
|
// invisible on an incremental build and fatal on a cold one.
|
|
if (!r.output.empty()) Progress::Diagnostic(r.output);
|
|
return "";
|
|
}
|
|
|
|
// A compiler killed outright (the OOM killer is the common one) prints
|
|
// nothing, so the old "output means failure" rule read it as success and
|
|
// let the build carry on with a missing object. Say what happened instead.
|
|
if (r.output.empty()) {
|
|
return r.crashed
|
|
? std::format("terminated by signal {}: {}", r.signal, cmd)
|
|
: std::format("exited with code {}: {}", r.exitCode, cmd);
|
|
}
|
|
if (r.crashed) {
|
|
return std::format("{}\nterminated by signal {}", r.output, r.signal);
|
|
}
|
|
return r.output;
|
|
}
|
|
|
|
// --- Dependencies of project.cpp itself -------------------------------------
|
|
//
|
|
// #pragma crafter dependency "<source>" [branch "<b>" | commit "<sha>"] [project "<file>"]
|
|
//
|
|
// names a sibling crafter-build project that project.cpp imports a module
|
|
// from. Anything a file needs before it can compile cannot be C++ inside that
|
|
// file, so this is a directive next to the `import` it enables — where
|
|
// `#include` lives. Each one is fetched, built for the host, and put on
|
|
// project.so's module path and link line before project.cpp is compiled. The
|
|
// dependency's own project.cpp goes through LoadProject, so its pragmas
|
|
// resolve recursively.
|
|
namespace {
|
|
struct PragmaDependency {
|
|
std::string source;
|
|
std::string branch;
|
|
std::string commit;
|
|
std::string project;
|
|
std::size_t line = 0;
|
|
};
|
|
|
|
[[noreturn]] void PragmaError(const fs::path& file, std::size_t line, std::string_view message) {
|
|
throw std::runtime_error(std::format("{}:{}: {}", file.string(), line, message));
|
|
}
|
|
|
|
// The tokens of one directive: bare words and double-quoted strings (no
|
|
// escapes — a path or URL never needs one). Reading stops at `//`.
|
|
class PragmaCursor {
|
|
public:
|
|
PragmaCursor(std::string_view text, const fs::path& file, std::size_t line) : text_(text), file_(file), line_(line) {}
|
|
|
|
bool AtEnd() {
|
|
SkipSpace();
|
|
return pos_ >= text_.size() || text_.substr(pos_).starts_with("//");
|
|
}
|
|
|
|
bool Take(char c) {
|
|
SkipSpace();
|
|
if (pos_ >= text_.size() || text_[pos_] != c) return false;
|
|
++pos_;
|
|
return true;
|
|
}
|
|
|
|
std::string_view Word() {
|
|
SkipSpace();
|
|
const std::size_t start = pos_;
|
|
while (pos_ < text_.size() && IsWordChar(text_[pos_])) ++pos_;
|
|
return text_.substr(start, pos_ - start);
|
|
}
|
|
|
|
std::optional<std::string_view> Quoted() {
|
|
SkipSpace();
|
|
if (pos_ >= text_.size() || text_[pos_] != '"') return std::nullopt;
|
|
const std::size_t close = text_.find('"', pos_ + 1);
|
|
if (close == std::string_view::npos) Fail("unterminated string");
|
|
const std::string_view inner = text_.substr(pos_ + 1, close - pos_ - 1);
|
|
pos_ = close + 1;
|
|
return inner;
|
|
}
|
|
|
|
[[noreturn]] void Fail(std::string_view message) const { PragmaError(file_, line_, message); }
|
|
|
|
private:
|
|
static bool IsWordChar(char c) { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'; }
|
|
|
|
void SkipSpace() {
|
|
while (pos_ < text_.size() && (text_[pos_] == ' ' || text_[pos_] == '\t' || text_[pos_] == '\r')) ++pos_;
|
|
}
|
|
|
|
std::string_view text_;
|
|
const fs::path& file_;
|
|
std::size_t line_;
|
|
std::size_t pos_ = 0;
|
|
};
|
|
|
|
// One directive per line, anywhere in the file. Comments aren't parsed:
|
|
// a directive inside a block comment is honoured, and `//` lines fall out
|
|
// because they don't start with `#`.
|
|
std::vector<PragmaDependency> ScanProjectFilePragmas(const fs::path& absProject) {
|
|
std::vector<PragmaDependency> found;
|
|
std::ifstream in(absProject);
|
|
std::string raw;
|
|
for (std::size_t lineNo = 1; std::getline(in, raw); ++lineNo) {
|
|
PragmaCursor cur(raw, absProject, lineNo);
|
|
if (!cur.Take('#') || cur.Word() != "pragma" || cur.Word() != "crafter") continue;
|
|
const std::string_view verb = cur.Word();
|
|
if (verb != "dependency") cur.Fail(std::format("unknown crafter pragma '{}'", verb));
|
|
|
|
PragmaDependency dep;
|
|
dep.line = lineNo;
|
|
const std::optional<std::string_view> source = cur.Quoted();
|
|
if (!source) cur.Fail("expected a quoted source after 'dependency'");
|
|
dep.source = *source;
|
|
while (!cur.AtEnd()) {
|
|
const std::string_view key = cur.Word();
|
|
std::string* field = key == "branch" ? &dep.branch : key == "commit" ? &dep.commit : key == "project" ? &dep.project : nullptr;
|
|
if (!field) cur.Fail(key.empty() ? "unexpected text after the source" : std::format("unknown keyword '{}' (expected branch, commit or project)", key));
|
|
if (!field->empty()) cur.Fail(std::format("'{}' given twice", key));
|
|
const std::optional<std::string_view> value = cur.Quoted();
|
|
if (!value || value->empty()) cur.Fail(std::format("expected a quoted value after '{}'", key));
|
|
*field = *value;
|
|
}
|
|
if (dep.source.empty()) cur.Fail("the source is empty");
|
|
if (!dep.branch.empty() && !dep.commit.empty()) cur.Fail("'branch' and 'commit' are mutually exclusive");
|
|
found.push_back(std::move(dep));
|
|
}
|
|
return found;
|
|
}
|
|
|
|
struct ProjectFileFrame {
|
|
fs::path project;
|
|
std::vector<Configuration*> dependencies;
|
|
};
|
|
|
|
// LoadProject nests: a dependency's project.cpp loads inside its
|
|
// consumer's pre-pass, and GitProject inside CrafterBuildProject nests
|
|
// again. Several threads may also load at once (ConcurrentCacheRace), so
|
|
// the stack is per thread. ProjectDependency() reads the top frame.
|
|
thread_local std::vector<ProjectFileFrame> LoadingFrames;
|
|
|
|
// Held for the whole of one LoadProject call. Pushing a file that is
|
|
// already on the stack is a cycle, which used to recurse forever.
|
|
class ProjectFileGuard {
|
|
public:
|
|
explicit ProjectFileGuard(const fs::path& absProject) {
|
|
for (const ProjectFileFrame& frame : LoadingFrames) {
|
|
if (frame.project != absProject) continue;
|
|
std::string chain;
|
|
for (const ProjectFileFrame& f : LoadingFrames) chain += std::format("{} -> ", f.project.string());
|
|
throw std::runtime_error(std::format("circular project dependency: {}{}", chain, absProject.string()));
|
|
}
|
|
index_ = LoadingFrames.size();
|
|
LoadingFrames.push_back({ absProject, {} });
|
|
}
|
|
~ProjectFileGuard() { LoadingFrames.pop_back(); }
|
|
ProjectFileGuard(const ProjectFileGuard&) = delete;
|
|
ProjectFileGuard& operator=(const ProjectFileGuard&) = delete;
|
|
|
|
ProjectFileFrame& Frame() { return LoadingFrames[index_]; }
|
|
|
|
private:
|
|
std::size_t index_ = 0;
|
|
};
|
|
|
|
// A pre-dependency is host tooling: project.so is loaded into the running
|
|
// crafter-build, so the dependency is built for the host and against the
|
|
// same PCM cache LoadProject populates — the hostConfig LoadProject itself
|
|
// compiles project.so with, so the dependency's std/Crafter.Build PCMs
|
|
// land in the cache dir project.so is built against. Recursive, because
|
|
// the dependency's project.cpp forwards no arguments but still reads
|
|
// CRAFTER_BUILD_MARCH from the environment for its own deps.
|
|
void ForceHostTooling(Configuration& cfg, const Configuration& host, std::unordered_set<Configuration*>& seen) {
|
|
if (!seen.insert(&cfg).second) return;
|
|
cfg.target = host.target;
|
|
cfg.march = host.march;
|
|
cfg.mtune = host.mtune;
|
|
cfg.debug = false;
|
|
// project.so is a shared object, so the archive it links has to be
|
|
// position-independent; static libraries aren't by default. Part of
|
|
// VariantId, so this build never shares a bin dir with a normal one.
|
|
// Checked before pushing: the same Configuration comes back from the
|
|
// project cache for every consumer in this process. Linux hosts only,
|
|
// glibc or musl: Windows targets have no use for the flag.
|
|
const bool linuxHost = host.target.ends_with("-linux-gnu") || host.target.ends_with("-linux-musl");
|
|
if (linuxHost && !std::ranges::contains(cfg.compileFlags, "-fPIC")) {
|
|
cfg.compileFlags.emplace_back("-fPIC");
|
|
}
|
|
for (Configuration* dep : cfg.dependencies) ForceHostTooling(*dep, host, seen);
|
|
}
|
|
|
|
struct ProjectFileDependencies {
|
|
std::vector<Configuration*> configs;
|
|
std::string compileFlags; // " -fprebuilt-module-path=…" for each dependency and its transitive deps
|
|
std::string linkFlags; // every dependency's BuildResult.libs, verbatim
|
|
fs::file_time_type newestArtifact = fs::file_time_type::min();
|
|
};
|
|
|
|
bool IsGitSource(std::string_view source) {
|
|
return source.contains("://") || source.starts_with("git@");
|
|
}
|
|
|
|
ProjectFileDependencies ResolveProjectFileDependencies(const fs::path& absProject, const Configuration& host) {
|
|
ProjectFileDependencies out;
|
|
const std::vector<PragmaDependency> pragmas = ScanProjectFilePragmas(absProject);
|
|
if (pragmas.empty()) return out;
|
|
|
|
std::unordered_map<fs::path, std::shared_future<BuildResult>> depResults;
|
|
std::mutex depMutex;
|
|
std::unordered_set<Configuration*> forced;
|
|
std::unordered_set<Configuration*> walked;
|
|
std::unordered_set<std::string> libs;
|
|
|
|
for (const PragmaDependency& pragma : pragmas) {
|
|
Configuration* cfg = nullptr;
|
|
if (IsGitSource(pragma.source)) {
|
|
GitProjectSpec spec;
|
|
spec.source = { pragma.source, pragma.branch, pragma.commit };
|
|
if (!pragma.project.empty()) spec.projectFile = pragma.project;
|
|
cfg = GitProject(spec);
|
|
} else {
|
|
if (!pragma.branch.empty() || !pragma.commit.empty()) PragmaError(absProject, pragma.line, "'branch' and 'commit' only apply to a git source");
|
|
// Relative to the project file, not the cwd: LoadProject runs
|
|
// from wherever the consumer was invoked.
|
|
fs::path local = (absProject.parent_path() / pragma.source).lexically_normal();
|
|
if (fs::is_directory(local)) {
|
|
local /= pragma.project.empty() ? fs::path("project.cpp") : fs::path(pragma.project);
|
|
} else if (!pragma.project.empty()) {
|
|
PragmaError(absProject, pragma.line, "'project' only applies to a directory source");
|
|
}
|
|
if (!fs::exists(local)) PragmaError(absProject, pragma.line, std::format("no such project file: {}", local.string()));
|
|
cfg = LocalProject({ .projectFile = local });
|
|
}
|
|
if (std::ranges::contains(out.configs, cfg)) continue;
|
|
|
|
ForceHostTooling(*cfg, host, forced);
|
|
BuildResult built;
|
|
{
|
|
Progress::Task task(std::format("Building project dependency {}", cfg->name));
|
|
built = Build(*cfg, depResults, depMutex);
|
|
}
|
|
if (!built.result.empty()) PragmaError(absProject, pragma.line, std::format("project dependency '{}' failed to build: {}", cfg->name, built.result));
|
|
out.configs.push_back(cfg);
|
|
libs.insert(built.libs.begin(), built.libs.end());
|
|
|
|
// The module path needs the dependency and everything it depends
|
|
// on, same as GetCompileCommand does for a consumer; the newest
|
|
// artifact over the same set is what project.so is stale against.
|
|
std::function<void(Configuration*)> walk = [&](Configuration* c) {
|
|
if (!walked.insert(c).second) return;
|
|
out.compileFlags += std::format(" -fprebuilt-module-path={}", c->PcmDir().string());
|
|
std::error_code ec;
|
|
const fs::file_time_type t = fs::last_write_time(ExpectedOutput(*c), ec);
|
|
if (!ec && t > out.newestArtifact) out.newestArtifact = t;
|
|
for (Configuration* sub : c->dependencies) walk(sub);
|
|
};
|
|
walk(cfg);
|
|
}
|
|
for (const std::string& lib : libs) out.linkFlags += std::format(" {}", lib);
|
|
return out;
|
|
}
|
|
|
|
// project.so is stale when its stamp (crafter-build version, toolchain,
|
|
// module sources and compile command) disagrees, or when it predates its
|
|
// source or a pre-dependency artifact. The executable's mtime is not an
|
|
// input any more: an OSTree deployment dates every installed file at the
|
|
// epoch, so a comparison against it never fired after an update and the
|
|
// old project.so was loaded into a binary it did not match.
|
|
bool ProjectFileStale(const fs::path& artifact, const fs::path& project, std::string_view stamp, const ProjectFileDependencies& deps) {
|
|
if (!fs::exists(artifact)) return true;
|
|
if (ReadStamp(StampPath(artifact)) != stamp) return true;
|
|
const fs::file_time_type built = fs::last_write_time(artifact);
|
|
return built < fs::last_write_time(project) || built < deps.newestArtifact;
|
|
}
|
|
}
|
|
|
|
Configuration* Crafter::ProjectDependency(std::string_view name) {
|
|
if (LoadingFrames.empty()) throw std::runtime_error(std::format("ProjectDependency('{}'): called outside a project file", name));
|
|
const ProjectFileFrame& frame = LoadingFrames.back();
|
|
for (Configuration* cfg : frame.dependencies) {
|
|
if (cfg->name == name) return cfg;
|
|
for (const std::unique_ptr<Module>& iface : cfg->interfaces) {
|
|
if (iface->name == name) return cfg;
|
|
}
|
|
}
|
|
std::string declared;
|
|
for (Configuration* cfg : frame.dependencies) {
|
|
if (!declared.empty()) declared += ", ";
|
|
declared += cfg->name;
|
|
}
|
|
throw std::runtime_error(std::format("ProjectDependency('{}'): not declared by {} (declared: {})", name, frame.project.string(), declared.empty() ? "none" : declared));
|
|
}
|
|
|
|
bool Crafter::IsTopLevelProject() {
|
|
// One frame is the project file crafter-build was pointed at; a second
|
|
// is only ever pushed for a dependency's project file.
|
|
return LoadingFrames.size() <= 1;
|
|
}
|
|
|
|
CommandResult Crafter::RunCommandChecked(std::string_view cmd) {
|
|
std::array<char, 128> buffer;
|
|
CommandResult result{};
|
|
|
|
std::string with = std::format("{} 2>&1", cmd);
|
|
FILE* pipe = popen(with.c_str(), "r");
|
|
if (!pipe) throw std::runtime_error("popen() failed!");
|
|
|
|
while (fgets(buffer.data(), buffer.size(), pipe) != nullptr) {
|
|
result.output += buffer.data();
|
|
}
|
|
|
|
std::int32_t status = pclose(pipe);
|
|
if (WIFEXITED(status)) {
|
|
result.exitCode = WEXITSTATUS(status);
|
|
} else if (WIFSIGNALED(status)) {
|
|
result.signal = WTERMSIG(status);
|
|
result.exitCode = 128 + result.signal;
|
|
result.crashed = true;
|
|
} else {
|
|
result.exitCode = -1;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
CommandResult Crafter::RunCommandWithTimeout(std::string_view cmd, std::chrono::seconds timeout) {
|
|
std::array<char, 128> buffer;
|
|
CommandResult result{};
|
|
|
|
// -k (not --kill-after=): the short form is what GNU coreutils and busybox
|
|
// (Alpine) have in common.
|
|
std::string wrapped = std::format("timeout -k 2 {} {} 2>&1", timeout.count(), cmd);
|
|
|
|
FILE* pipe = popen(wrapped.c_str(), "r");
|
|
if (!pipe) throw std::runtime_error("popen() failed!");
|
|
|
|
while (fgets(buffer.data(), buffer.size(), pipe) != nullptr) {
|
|
result.output += buffer.data();
|
|
}
|
|
|
|
std::int32_t status = pclose(pipe);
|
|
if (WIFEXITED(status)) {
|
|
std::int32_t code = WEXITSTATUS(status);
|
|
if (code == 124) {
|
|
result.timedOut = true;
|
|
result.exitCode = 124;
|
|
} else if (code >= 128) {
|
|
result.signal = code - 128;
|
|
result.exitCode = code;
|
|
result.crashed = true;
|
|
} else {
|
|
result.exitCode = code;
|
|
}
|
|
} else if (WIFSIGNALED(status)) {
|
|
result.signal = WTERMSIG(status);
|
|
result.exitCode = 128 + result.signal;
|
|
result.crashed = true;
|
|
} else {
|
|
result.exitCode = -1;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
std::string Crafter::BuildStdPcm(const Configuration& config, fs::path stdPcm) {
|
|
CacheLock lock(stdPcm.parent_path());
|
|
if(config.target == "x86_64-w64-mingw32") {
|
|
std::vector<std::string> folders;
|
|
|
|
// Iterate through the directory and collect all subdirectories
|
|
for (const auto& entry : fs::directory_iterator(GetToolchain().mingwSysroot / "include" / "c++")) {
|
|
if (entry.is_directory()) {
|
|
folders.push_back(entry.path().filename().string());
|
|
}
|
|
}
|
|
|
|
// Sort the folders by version in descending order
|
|
std::sort(folders.begin(), folders.end(), [](const std::string& a, const std::string& b) {
|
|
return std::lexicographical_compare(b.begin(), b.end(), a.begin(), a.end());
|
|
});
|
|
|
|
std::string mingWversion = folders.front();
|
|
|
|
fs::path stdCc = GetToolchain().mingwSysroot / "include" / "c++" / mingWversion / "bits" / "std.cc";
|
|
|
|
// -femulated-tls keeps PCM TLS access matching libstdc++'s emutls
|
|
// definitions; mismatch surfaces as undefined std::__once_callable.
|
|
// The std.cc → std.cppm copy uses a plain filename: held under the
|
|
// cache lock, no other builder writes this path concurrently.
|
|
fs::path stdCppm = stdPcm.parent_path() / "std.cppm";
|
|
std::string command = std::format("{} --target={} -march={} -mtune={} -femulated-tls -O3 -std=c++26 -Wno-reserved-identifier -Wno-reserved-module-identifier --precompile {} -o {}", GetBaseCommand(config), config.target, config.march, config.mtune, stdCppm.string(), stdPcm.string());
|
|
std::string stamp = HashStamp(command + '\0' + FileBytes(stdCc));
|
|
if (fs::exists(stdPcm) && ReadStamp(StampPath(stdPcm)) == stamp) return "";
|
|
std::error_code copyEc;
|
|
fs::copy_file(stdCc, stdCppm, fs::copy_options::overwrite_existing, copyEc);
|
|
if (copyEc) {
|
|
return std::format("copy {} -> {}: {}", stdCc.string(), stdCppm.string(), copyEc.message());
|
|
}
|
|
std::string result = RunBuildCommand(command);
|
|
if (result.empty()) WriteStamp(StampPath(stdPcm), stamp);
|
|
return result;
|
|
} else {
|
|
const bool isWasm = config.target.starts_with("wasm32");
|
|
// wasi-sdk drops std.cppm at <sysroot>/share/libc++/v1/, the rest of
|
|
// the libc++ ecosystem (e.g. /opt/aarch64-rootfs) follows FHS at
|
|
// <sysroot>/usr/share/libc++/v1/.
|
|
std::string stdCppm;
|
|
if (isWasm) {
|
|
stdCppm = std::format("{}/share/libc++/v1/std.cppm", config.sysroot);
|
|
} else if (config.sysroot.empty()) {
|
|
stdCppm = GetToolchain().stdCppm.string();
|
|
} else {
|
|
stdCppm = std::format("{}/usr/share/libc++/v1/std.cppm", config.sysroot);
|
|
// not every distro ships the std module sources (Alpine's
|
|
// libc++-dev doesn't); the host copy is interchangeable as long
|
|
// as the libc++ versions line up, which the compile of the PCM
|
|
// against the sysroot's headers verifies anyway
|
|
if (!fs::exists(stdCppm) && fs::exists(GetToolchain().stdCppm)) {
|
|
stdCppm = GetToolchain().stdCppm.string();
|
|
}
|
|
}
|
|
std::string sysrootFlag = config.sysroot.empty()
|
|
? std::string()
|
|
: std::format(" --sysroot={}", config.sysroot);
|
|
// wasm32 rejects -march. wasi-libc++ headers require these baselines
|
|
// to compile: setjmp.h needs -mllvm -wasm-enable-sjlj (lowered to wasm
|
|
// EH); signal.h requires the emulation define; and EH itself isn't
|
|
// wired up so -fno-exceptions stays.
|
|
std::string archFlags = isWasm
|
|
? std::string(" -fno-exceptions -msimd128 -fno-c++-static-destructors -mllvm -wasm-enable-sjlj -D_WASI_EMULATED_SIGNAL")
|
|
: std::format(" -march={} -mtune={}", config.march, config.mtune);
|
|
// Match the variant codegen flags the consuming TUs are built with
|
|
// (e.g. -mrelaxed-simd) so the std BMI is feature-compatible. wasm only;
|
|
// wasmVariantFlags is empty on every other path.
|
|
if (isWasm) {
|
|
for (const std::string& f : config.wasmVariantFlags) archFlags += std::format(" {}", f);
|
|
}
|
|
// non-wasm cross builds: the driver ranks the host toolchain's own
|
|
// libc++ headers (<install>/../include/c++/v1) above --sysroot, so
|
|
// the PCM would mix host libc++ with the foreign libc — force the
|
|
// sysroot's copy.
|
|
if (!isWasm && !config.sysroot.empty()) {
|
|
archFlags += std::format(" -nostdinc++ -isystem {}/usr/include/c++/v1", config.sysroot);
|
|
}
|
|
// Keyed on the command and the module source's bytes, never on
|
|
// mtimes: an installed std.cppm can be dated at the epoch (OSTree),
|
|
// and a libc++ update under the same clang changes the bytes but
|
|
// not the path.
|
|
std::string command = std::format("{} --target={} -std=c++26{}{} -O3 -Wno-reserved-identifier -Wno-reserved-module-identifier --precompile {} -o {}", GetBaseCommand(config), config.target, sysrootFlag, archFlags, stdCppm, stdPcm.string());
|
|
std::string stamp = HashStamp(command + '\0' + FileBytes(stdCppm));
|
|
if (fs::exists(stdPcm) && ReadStamp(StampPath(stdPcm)) == stamp) return "";
|
|
std::string result = RunBuildCommand(command);
|
|
if (result.empty()) WriteStamp(StampPath(stdPcm), stamp);
|
|
return result;
|
|
}
|
|
}
|
|
|
|
fs::path Crafter::GetCacheDir() {
|
|
if (const char* xdg = std::getenv("XDG_CACHE_HOME"); xdg && *xdg) {
|
|
return fs::path(xdg) / "crafter.build";
|
|
}
|
|
|
|
if (const char* home = std::getenv("HOME")) {
|
|
return fs::path(home) / ".cache" / "crafter.build";
|
|
}
|
|
|
|
throw std::runtime_error("Neither XDG_CACHE_HOME nor HOME set");
|
|
}
|
|
|
|
std::string Crafter::GetBaseCommand(const Configuration& config) {
|
|
const Toolchain& tc = GetToolchain();
|
|
std::string command = tc.clangxx.string();
|
|
if (config.target == "x86_64-w64-mingw32") {
|
|
// libstdc++ from the mingw toolchain, found by clang's own mingw
|
|
// discovery unless the sysroot was given explicitly.
|
|
if (tc.mingwSysrootExplicit) command += std::format(" --sysroot={}", tc.mingwSysroot.string());
|
|
return command;
|
|
}
|
|
command += " -stdlib=libc++";
|
|
// A sysroot build (wasm, a cross rootfs) takes libc++ from the sysroot;
|
|
// only host builds can be pointed at an explicit libc++ install.
|
|
if (config.sysroot.empty()) command += tc.LibcxxCompileFlags();
|
|
return command;
|
|
}
|
|
|
|
namespace {
|
|
constexpr std::array<std::string_view, 12> CrafterBuildModules = {
|
|
"Crafter.Build-Toolchain",
|
|
"Crafter.Build-Shader",
|
|
"Crafter.Build-Platform",
|
|
"Crafter.Build-Interface",
|
|
"Crafter.Build-Implementation",
|
|
"Crafter.Build-External",
|
|
"Crafter.Build-Clang",
|
|
"Crafter.Build-Test",
|
|
"Crafter.Build-Lint",
|
|
"Crafter.Build-Progress",
|
|
"Crafter.Build-Asset",
|
|
"Crafter.Build",
|
|
};
|
|
|
|
// Returns the stamp of the module sources the cache was built from; it is
|
|
// one of project.so's inputs.
|
|
std::string EnsureCrafterBuildPcms(const fs::path& sourceDir, const fs::path& cacheDir, const Configuration& host) {
|
|
CacheLock lock(cacheDir);
|
|
// Re-derived under the lock: another builder may have refreshed the
|
|
// cache from its own sources while we waited.
|
|
// The std PCM is an input to every module PCM: its stamp is folded in
|
|
// so a rebuilt std.pcm (a libc++ update under the same clang) rebuilds
|
|
// them too, instead of leaving PCMs that import a std.pcm that is gone.
|
|
std::string stamp = HashStamp(CrafterBuildSourceStamp(sourceDir, CrafterBuildModules) + '\0' + ReadStamp(StampPath(cacheDir / "std.pcm")));
|
|
const bool upToDate = ReadStamp(CacheStampPath(cacheDir)) == stamp;
|
|
for (std::string_view name : CrafterBuildModules) {
|
|
fs::path cppmPath = sourceDir / std::format("{}.cppm", name);
|
|
fs::path pcmPath = cacheDir / std::format("{}.pcm", name);
|
|
if (!fs::exists(cppmPath)) {
|
|
throw std::runtime_error(std::format("module source {} not found in {} (set CRAFTER_BUILD_HOME)", name, sourceDir.string()));
|
|
}
|
|
if (upToDate && fs::exists(pcmPath)) {
|
|
continue;
|
|
}
|
|
std::string cmd = std::format(
|
|
"{} --target={} -march=native -mtune=native "
|
|
"-std=c++26 -O3 "
|
|
"-Wno-reserved-identifier -Wno-reserved-module-identifier "
|
|
"-fprebuilt-module-path={} "
|
|
"--precompile {} -o {}",
|
|
GetBaseCommand(host), host.target, cacheDir.string(), cppmPath.string(), pcmPath.string());
|
|
CommandResult r = Crafter::RunCommandChecked(cmd);
|
|
if (r.exitCode != 0) {
|
|
throw std::runtime_error(std::format("Failed to precompile {} (exit {}): {}", name, r.exitCode, r.output));
|
|
}
|
|
}
|
|
// Last, so an interrupted rebuild leaves the stamp disagreeing with the
|
|
// sources and the next run starts over rather than trusting a half-built
|
|
// set of PCMs.
|
|
if (!upToDate) WriteStamp(CacheStampPath(cacheDir), stamp);
|
|
return stamp;
|
|
}
|
|
}
|
|
|
|
Configuration Crafter::LoadProject(const fs::path& projectFile, std::span<const std::string_view> args) {
|
|
RequireToolchain();
|
|
fs::path absProject = fs::canonical(projectFile);
|
|
ProjectFileGuard guard(absProject);
|
|
fs::path buildDir = absProject.parent_path() / "build";
|
|
if (!fs::exists(buildDir)) {
|
|
fs::create_directories(buildDir);
|
|
}
|
|
fs::path soPath = buildDir / std::format("{}.so", absProject.stem().string());
|
|
|
|
fs::path sourceDir = Crafter::GetCrafterBuildHome();
|
|
|
|
// The host toolchain's own triple (x86_64-pc-linux-gnu on glibc distros,
|
|
// x86_64-alpine-linux-musl on Alpine): project.cpp and the module PCMs
|
|
// it imports must be built for the libc crafter-build itself runs on.
|
|
Configuration hostConfig;
|
|
hostConfig.target = HostTarget();
|
|
hostConfig.march = "native";
|
|
hostConfig.mtune = "native";
|
|
// The same directory Build() resolves the std PCM from, toolchain key
|
|
// included (see StdPcmDir).
|
|
fs::path cacheDir = StdPcmDir(hostConfig);
|
|
if (!fs::exists(cacheDir)) fs::create_directories(cacheDir);
|
|
|
|
std::string stdResult = BuildStdPcm(hostConfig, cacheDir / "std.pcm");
|
|
if (!stdResult.empty()) {
|
|
throw std::runtime_error(std::format("Failed to build std.pcm: {}", stdResult));
|
|
}
|
|
|
|
const std::string sourcesStamp = EnsureCrafterBuildPcms(sourceDir, cacheDir, hostConfig);
|
|
|
|
// After the host PCMs exist (a dependency's build imports Crafter.Build
|
|
// from that cache) and before the staleness check (its artifacts are
|
|
// inputs to project.so). Built on every load, fresh or not: a dependency
|
|
// BMI whose own imports were rebuilt underneath it doesn't fail cleanly,
|
|
// clang crashes on it.
|
|
ProjectFileDependencies preDeps = ResolveProjectFileDependencies(absProject, hostConfig);
|
|
|
|
// -fuse-ld=lld like every other link the tool performs: lld is a hard
|
|
// dependency, binutils' ld is not (Alpine's clang would default to it),
|
|
// and a dependency archive built in release holds ThinLTO bitcode,
|
|
// which GNU ld can't read.
|
|
std::string compileCmd = std::format(
|
|
"{} --target={} -march=native -mtune=native "
|
|
"-std=c++26 -shared -fPIC -O3 -fuse-ld=lld "
|
|
"-Wno-return-type-c-linkage "
|
|
"-fprebuilt-module-path={}{} "
|
|
"{} -o {}{}{}",
|
|
GetBaseCommand(hostConfig), hostConfig.target, cacheDir.string(), preDeps.compileFlags,
|
|
absProject.string(), soPath.string(), preDeps.linkFlags, GetToolchain().LibcxxLinkFlags());
|
|
// What project.so was compiled against. The module source stamp stands
|
|
// in for the executable: project.so resolves Crafter:: symbols from the
|
|
// running binary, so only the declarations (the .cppm files) matter,
|
|
// and an implementation-only rebuild of crafter-build leaves it valid.
|
|
const std::string projectStamp = HashStamp(std::format("{}\0{}\0{}\0{}", CrafterBuildVersion(), GetToolchain().Key(), sourcesStamp, compileCmd));
|
|
|
|
if (ProjectFileStale(soPath, absProject, projectStamp, preDeps)) {
|
|
std::string result = RunBuildCommand(compileCmd);
|
|
if (!result.empty()) {
|
|
throw std::runtime_error(std::format("Failed to compile project {}: {}", absProject.string(), result));
|
|
}
|
|
WriteStamp(StampPath(soPath), projectStamp);
|
|
}
|
|
|
|
void* handle = dlopen(soPath.c_str(), RTLD_NOW | RTLD_GLOBAL);
|
|
if (!handle) {
|
|
throw std::runtime_error(std::format("Failed to load project {}: {}", soPath.string(), dlerror()));
|
|
}
|
|
|
|
using ProjectFn = Configuration (*)(std::span<const std::string_view>);
|
|
dlerror();
|
|
auto fn = reinterpret_cast<ProjectFn>(dlsym(handle, "CrafterBuildProject"));
|
|
if (const char* err = dlerror()) {
|
|
throw std::runtime_error(std::format("CrafterBuildProject not found in {}: {}", soPath.string(), err));
|
|
}
|
|
|
|
guard.Frame().dependencies = std::move(preDeps.configs);
|
|
return fn(args);
|
|
}
|