Crafter.Build/implementations/Crafter.Build-Platform.cpp
Jorijn van der Graaf 63c4040000
feat(wasm): compile C++ with WebAssembly exception handling
wasm builds were compiled with -fno-exceptions. A throw did not
compile, libc++'s own throws (vector::at, std::format, filesystem)
aborted the program, and code that relies on exceptions could not be
built for the browser at all.

Every wasm unit and the std module now get -fwasm-exceptions, and the
link adds -lunwind, which code built that way calls into and the driver
does not add. The flag also selects the libc++ under the sysroot's
lib/<triple>/eh, which wasi-sdk 33 and later ship next to the noeh
one. Those libraries use the standardized encoding (try_table, exnref)
while LLVM 23 still emits the legacy one by default, which wasmtime
refuses to load and which would put both in one binary, so the flags
ask for the standard form with -wasm-use-legacy-eh=false. That makes
the setjmp lowering loadable under wasmtime too.

The flags live in one constant, WasmBaselineFlags, which the compile
command and the std module both take through WasmCompileFlags: a BMI
is refused by a unit whose exception setting differs from its own.
VariantId and StdPcmDir hash it, so the first build after the update
starts fresh instead of importing PCMs built without exceptions, and
two releases sharing the Flatpak's cache don't take turns rebuilding
one std PCM. A sysroot without the exception-enabled libraries stops
the build with a line saying so, rather than at the link with "unable
to find library -lunwind".

The result needs an engine with exnref: Chrome 137, Firefox 131,
Safari 18.4, the bundled wasmtime. The runtime's exception-handling
probe tested the legacy encoding; it now tests try_table, every
variants.json entry carries it, and a browser without it is told which
feature it lacks instead of failing with a CompileError. There is no
switch back to -fno-exceptions: engines without exnref are old enough
not to keep a second build for them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 05:54:39 +01:00

1048 lines
49 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.
//
// A FileLock on a `.lock` file in the cache dir.
struct CacheLock {
FileLock lock;
explicit CacheLock(const fs::path& cacheDir) : lock(cacheDir / ".lock") {}
};
// 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();
}
// The string value of `"key": "..."` in `object`, if it has one.
std::optional<std::string> JsonString(std::string_view object, std::string_view key) {
const std::string quoted = std::format("\"{}\"", key);
std::size_t at = object.find(quoted);
if (at == std::string_view::npos) return std::nullopt;
at = object.find_first_not_of(" \t\r\n", at + quoted.size());
if (at == std::string_view::npos || object[at] != ':') return std::nullopt;
at = object.find_first_not_of(" \t\r\n", at + 1);
if (at == std::string_view::npos || object[at] != '"') return std::nullopt;
std::string value;
for (std::size_t i = at + 1; i < object.size(); ++i) {
if (object[i] == '"') return value;
if (object[i] == '\\' && i + 1 < object.size()) ++i;
value += object[i];
}
return std::nullopt;
}
// The std module's "source-path" in a libstdc++.modules.json: the object
// in its "modules" array whose "logical-name" is "std". The manifest is
// a flat list of such objects, so each one is the text between its
// braces.
std::optional<std::string> StdModuleSourcePath(std::string_view manifest) {
for (std::size_t open = manifest.find('{', manifest.find("\"modules\"")); open != std::string_view::npos; open = manifest.find('{', open + 1)) {
const std::size_t close = manifest.find('}', open);
if (close == std::string_view::npos) break;
const std::string_view object = manifest.substr(open, close - open);
if (JsonString(object, "logical-name") == "std") return JsonString(object, "source-path");
}
return std::nullopt;
}
}
Crafter::FileLock::FileLock(const fs::path& path) {
fd = ::open(path.c_str(), O_RDWR | O_CREAT | O_CLOEXEC, 0644);
if (fd >= 0) {
while (::flock(fd, LOCK_EX) != 0 && errno == EINTR) {}
}
}
Crafter::FileLock::~FileLock() {
if (fd >= 0) {
::close(fd); // closing the descriptor drops the flock
}
}
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;
// What LoadProject was given, for ProjectArgs(), and only once
// CrafterBuildProject is about to be called (see LoadProject). A view
// is enough: the caller's storage outlives LoadProject, and the frame
// is popped before LoadProject returns.
std::span<const std::string_view> args;
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;
// A frame's dependencies, kept past the frame for PragmaDependencies().
// Process-wide rather than per thread: whoever asks need not be the
// thread that loaded the file. The Configurations live in the project
// cache, which outlives every load.
std::mutex PragmaDependenciesMutex;
std::unordered_map<fs::path, std::vector<Configuration*>> PragmaDependenciesByProject;
// 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;
};
// How many `#pragma crafter dependency` loads this thread is inside. The
// tool's project file runs within its load, and so does every sibling it
// loads in turn, which is what LoadingHostTooling() reports to GitProject
// and LocalProject. Per thread like LoadingFrames: a sibling loaded on a
// thread of the tool's own goes uncounted, as it gets no ProjectArgs().
thread_local std::int32_t HostToolingLoads = 0;
class HostToolingScope {
public:
HostToolingScope() { ++HostToolingLoads; }
~HostToolingScope() { --HostToolingLoads; }
HostToolingScope(const HostToolingScope&) = delete;
HostToolingScope& operator=(const HostToolingScope&) = delete;
};
// 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 is loaded with no arguments and so passes
// none on, while its own deps still read CRAFTER_BUILD_MARCH from the
// environment.
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::set<std::string> libs;
for (const PragmaDependency& pragma : pragmas) {
// Neither spec sets args, and the tool still gets none of the
// consumer's: this pre-pass runs before LoadProject hands the
// frame its args, so ProjectArgs() is empty. Given the consumer's
// --target=, the tool's project.cpp would pick sources and flags
// for the wrong triple, and its flags would split the tool's
// variant from the one every other consumer shares. The scope
// keeps the tool and everything it loads in a project cache of
// their own: ForceHostTooling rewrites them in place, and a
// consumer loading the same sibling for itself got that host
// build back whenever the two cache keys matched.
Configuration* cfg = nullptr;
{
HostToolingScope hostTooling;
if (IsGitSource(pragma.source)) {
GitProjectSpec 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;
}
std::vector<Configuration*> Crafter::PragmaDependencies(const fs::path& projectFile) {
std::error_code ec;
const fs::path absProject = fs::canonical(projectFile, ec);
if (ec) return {};
std::lock_guard lock(PragmaDependenciesMutex);
if (auto it = PragmaDependenciesByProject.find(absProject); it != PragmaDependenciesByProject.end()) return it->second;
return {};
}
std::vector<std::string> Crafter::ProjectArgs() {
if (LoadingFrames.empty()) return {};
const std::span<const std::string_view> args = LoadingFrames.back().args;
return std::vector<std::string>(args.begin(), args.end());
}
bool Crafter::LoadingHostTooling() {
return HostToolingLoads > 0;
}
namespace {
// The one reading of a wait status (pclose, system) for every helper
// that runs a command: a signal death is reported the way a shell
// reports it, as 128+signal.
void DecodeWaitStatus(std::int32_t status, CommandResult& result) {
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;
}
}
}
std::string Crafter::ShellQuote(std::string_view s) {
std::string out;
out.reserve(s.size() + 2);
out.push_back('\'');
for (char c : s) {
if (c == '\'') out += "'\\''";
else out.push_back(c);
}
out.push_back('\'');
return out;
}
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();
}
DecodeWaitStatus(pclose(pipe), result);
return result;
}
std::int32_t Crafter::RunCommandAttached(std::string_view cmd) {
// The child writes straight to the terminal. Anything of ours that is
// still pending — the in-place status line, buffered stdout when it is
// a pipe — has to be out before the child's first byte, or it lands in
// the middle of the child's output or after it.
Progress::Clear();
std::fflush(stdout);
std::fflush(stderr);
const std::string command(cmd);
const std::int32_t status = std::system(command.c_str());
if (status == -1) throw std::runtime_error(std::format("could not start a shell for: {}", cmd));
CommandResult result{};
DecodeWaitStatus(status, result);
return result.exitCode;
}
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();
}
DecodeWaitStatus(pclose(pipe), result);
// What comes back is `timeout`'s own exit, and it speaks through the
// code: 124 for a command it had to stop, 128+signal for one that died.
if (!result.crashed) {
if (result.exitCode == 124) {
result.timedOut = true;
} else if (result.exitCode >= 128) {
result.signal = result.exitCode - 128;
result.crashed = true;
}
}
return result;
}
std::string Crafter::BuildStdPcm(const Configuration& config, fs::path stdPcm) {
CacheLock lock(stdPcm.parent_path());
if(config.target == "x86_64-w64-mingw32") {
// libstdc++ 15 and newer install libstdc++.modules.json beside the
// library, naming the std module's source relative to itself. It is
// looked for in the library directories the compiler searches for
// this target, as it would look for libstdc++ itself, but only in
// those that belong to the mingw toolchain: the list also has the
// generic <prefix>/lib, where the host's own (Linux) libstdc++ keeps
// its manifest. A toolchain that isn't there is a build error, not
// an exception: Build() runs this with its worker threads started.
const Toolchain& tc = GetToolchain();
const std::string where = tc.mingwSysroot.empty()
? std::string("where clang finds it by the x86_64-w64-mingw32 gcc on PATH")
: std::format("under {}", tc.mingwSysroot.string());
const std::string missing = std::format("no mingw-w64 libstdc++ with the std module (libstdc++ 15 or newer) {}. Install mingw-w64, or point CRAFTER_MINGW_DIR at its prefix, the directory holding x86_64-w64-mingw32/ and lib/gcc/", where);
// Asked once a run for each compiler and target: every mingw
// configuration comes through here, up to date or not, and the answer
// costs a clang.
static std::mutex probeMutex;
static std::unordered_map<std::string, CommandResult> probes;
const std::string probe = std::format("{} --target={} -print-search-dirs", GetBaseCommand(config), config.target);
CommandResult dirs;
{
std::lock_guard guard(probeMutex);
auto found = probes.find(probe);
if (found == probes.end()) found = probes.emplace(probe, RunCommandChecked(probe)).first;
dirs = found->second;
}
if (dirs.exitCode != 0) return std::format("{}: {}", missing, dirs.output);
fs::path manifestPath;
constexpr std::string_view librariesLine = "libraries: =";
if (const std::size_t at = dirs.output.find(librariesLine); at != std::string::npos) {
const std::size_t end = dirs.output.find('\n', at);
const std::string list = dirs.output.substr(at + librariesLine.size(), end == std::string::npos ? std::string::npos : end - at - librariesLine.size());
for (const auto part : std::views::split(list, ':')) {
const fs::path dir(std::string_view(part.begin(), part.end()));
const bool mingwOwn = std::ranges::contains(dir, fs::path("x86_64-w64-mingw32"));
if (mingwOwn && fs::exists(dir / "libstdc++.modules.json")) {
manifestPath = dir / "libstdc++.modules.json";
break;
}
}
}
if (manifestPath.empty()) return missing;
const std::optional<std::string> source = StdModuleSourcePath(FileBytes(manifestPath));
if (!source) return std::format("{} names no std module", manifestPath.string());
const fs::path stdCc = (manifestPath.parent_path() / *source).lexically_normal();
if (!fs::exists(stdCc)) return std::format("{}, which {} names as the std module, is missing", stdCc.string(), manifestPath.string());
// -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);
// wasm builds link the sysroot's exception-enabled libc++, which
// -fwasm-exceptions in WasmBaselineFlags selects, and the
// libunwind Build adds. A sysroot without them (wasi-sdk before
// 33, or one built without its exceptions option) otherwise stops
// at the first link with "unable to find library -lunwind", which
// says nothing about why it was asked for. The driver looks in eh/
// first and then in the triple's own directory.
const fs::path libDir = fs::path(config.sysroot) / "lib" / NormalizeTarget(config.target);
if (!fs::exists(libDir / "eh" / "libunwind.a") && !fs::exists(libDir / "libunwind.a")) {
return std::format("the WASI sysroot at {} has no C++ exception support (no {}): wasm builds use WebAssembly exception handling and need the exception-enabled libc++ and libunwind that wasi-sdk 33 and later ship. The Flatpak bundles such a sysroot; CRAFTER_BUILD_WASI_SYSROOT selects another", config.sysroot, (libDir / "eh" / "libunwind.a").string());
}
} else if (config.sysroot.empty()) {
stdCppm = GetToolchain().stdCppm.string();
} else {
// The source has to belong to the libc++ release whose headers
// the sysroot carries. Handing clang the toolchain's own copy
// regardless used to end in a page of "no member named ... in
// namespace 'std'" once the two releases differed.
StdModuleSource source = SysrootStdModule(GetToolchain(), config.sysroot);
if (!source.error.empty()) return source.error;
stdCppm = source.stdCppm.string();
}
std::string sysrootFlag = config.sysroot.empty()
? std::string()
: std::format(" --sysroot={}", config.sysroot);
// wasm32 rejects -march, and the std module has to be compiled the way
// the units importing it are, a variant pass's flags (e.g.
// -mrelaxed-simd) included, or the BMI is not feature-compatible.
std::string archFlags = isWasm
? WasmCompileFlags(config)
: std::format(" -march={} -mtune={}", config.march, config.mtune);
// 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, said outright: a clang built to
// default to libc++ (the Flatpak's) would otherwise reach for that.
// Found by clang's own mingw discovery unless there is a sysroot.
command += " -stdlib=libstdc++";
if (!tc.mingwSysroot.empty()) 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, 14> CrafterBuildModules = {
"Crafter.Build-Toolchain",
"Crafter.Build-Shader",
"Crafter.Build-Platform",
"Crafter.Build-Interface",
"Crafter.Build-Implementation",
"Crafter.Build-External",
"Crafter.Build-Agents",
"Crafter.Build-Clang",
"Crafter.Build-Test",
"Crafter.Build-Lint",
"Crafter.Build-Script",
"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.
// The separators are appended, not written into a format string: a
// format string is read as a C string, so one with "\0" in it ends at the
// first NUL and everything after the version silently dropped out of the
// stamp. A project.so compiled against other declarations (a Shader with
// a different layout, say) was then loaded as current and corrupted the
// heap.
std::string projectStampInput(CrafterBuildVersion());
for (std::string_view part : {std::string_view(GetToolchain().Key()), std::string_view(sourcesStamp), std::string_view(compileCmd)}) {
projectStampInput += '\0';
projectStampInput += part;
}
const std::string projectStamp = HashStamp(projectStampInput);
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);
}
// Before dlopen, so the file's static initialisers can already call
// ProjectDependency(): its dependencies are the same on every load.
{
std::lock_guard lock(PragmaDependenciesMutex);
PragmaDependenciesByProject[absProject] = preDeps.configs;
}
guard.Frame().dependencies = std::move(preDeps.configs);
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));
}
// The args only for the call itself, unlike the dependencies. A static
// initialiser runs once per process, on the first load of the file: a
// later load gets the same mapping back from dlopen, so a spec built at
// namespace scope kept the first load's args for every load after it.
// Now it gets none, whichever load ran it. The pragma pre-pass above
// also ran on this frame and relies on the args not being there yet.
guard.Frame().args = args;
return fn(args);
}