fix(external): fetch and build each clone once per build pass
`crafter-build test` builds a project's tests side by side, and a
project whose tests each copy its externalDependencies hands every one
of them the same unpinned dependency. Each test's Build() pulled the
shared clone. Before the clone had a lock, those pulls ran at the same
time and broke its .git/FETCH_HEAD, which failed every test of
Crafter.Graphics (issue #34). Since 653dd0b they wait for each other,
but every one is still a network round trip: 26 tests cost 26 pulls of
each dependency, one after another, and for a CMake dependency as many
configure checks and builds.
depResults already records one build pass, keyed by the PcmDir of every
dependency configuration built in it. An external dependency now goes
in the same map, keyed by the directory its build leaves
(ExternalBuildDir: the clone, or for CMake its build directory). The
first configuration that needs it fetches and builds it; the others
wait for that and take its outcome. Keying on the build directory
rather than the clone keeps a CMake declaration from taking the outcome
of a header-only declaration of the same clone, which only fetched it.
A fresh depResults is a fresh pass, so the next build still pulls, and
every caller of Build() gets this: RunTests, a plain build, and the
pragma dependencies LoadProject builds.
To make that possible BuildExternal is split. BuildExternal is now the
shared work, lock, fetch, configure and build, and returns the error.
ExternalOutputs computes what one declaration compiles and links with,
because two declarations of one clone can differ in includeDirs,
libDirs and libs; ExternalBuildResult loses its error field. The work
no longer stops between steps when the configuration that started it
is cancelled: others may be waiting on it, and a run cut short would
leave them a clone nobody fetched. A dependency configuration's Build()
already runs to completion for the same reason.
The promise and future the dependency threads used to build or wait for
a configuration are now one helper, OncePerPass, which both kinds use.
An exception thrown in BuildExternal used to escape its thread into
std::terminate; it is now a build error, as for a dependency.
With eight tests that share a local dependency, a test run fetched it
eight times before this and fetches it once now (SharedExternalFetch,
next commit).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
0cbded4a3d
commit
f77d913171
6 changed files with 141 additions and 109 deletions
|
|
@ -231,15 +231,16 @@ std::vector<std::string> Crafter::ExternalIncludeFlags(const ExternalDependency&
|
|||
return flags;
|
||||
}
|
||||
|
||||
ExternalBuildResult Crafter::BuildExternal(const ExternalDependency& dep, std::string_view target, std::atomic<bool>& cancelled) {
|
||||
ExternalBuildResult result;
|
||||
|
||||
if (cancelled.load(std::memory_order_relaxed)) return result;
|
||||
fs::path Crafter::ExternalBuildDir(const ExternalDependency& dep, std::string_view target) {
|
||||
fs::path cloneDir = ExternalCloneDir(dep, target);
|
||||
if (cloneDir.empty() || dep.builder != ExternalBuilder::CMake) return cloneDir;
|
||||
return cloneDir / "build";
|
||||
}
|
||||
|
||||
std::string Crafter::BuildExternal(const ExternalDependency& dep, std::string_view target) {
|
||||
std::string name = dep.name.empty() ? DeriveName(dep.source) : dep.name;
|
||||
if (name.empty()) {
|
||||
result.error = std::format("Could not derive name for external dependency from URL '{}'", dep.source.url);
|
||||
return result;
|
||||
return std::format("Could not derive name for external dependency from URL '{}'", dep.source.url);
|
||||
}
|
||||
|
||||
fs::path externalRoot = GetCacheDir() / "external";
|
||||
|
|
@ -247,77 +248,73 @@ ExternalBuildResult Crafter::BuildExternal(const ExternalDependency& dep, std::s
|
|||
std::error_code ec;
|
||||
fs::create_directories(externalRoot, ec);
|
||||
if (ec) {
|
||||
result.error = std::format("Failed to create {}: {}", externalRoot.string(), ec.message());
|
||||
return result;
|
||||
return std::format("Failed to create {}: {}", externalRoot.string(), ec.message());
|
||||
}
|
||||
}
|
||||
|
||||
fs::path cloneDir = ExternalCloneDir(dep, target);
|
||||
// Builds running side by side (tests built in parallel, two
|
||||
// crafter-builds) share the clone: one fetches and builds it at a time.
|
||||
// Build() makes one call for all the configurations of a pass, but two
|
||||
// crafter-builds, or two passes, can still reach the same clone at once,
|
||||
// and two pulls in one clone race on its .git/FETCH_HEAD.
|
||||
FileLock lock(fs::path(std::format("{}.lock", cloneDir.string())));
|
||||
|
||||
std::string fetchErr = FetchGit(dep.source, cloneDir);
|
||||
if (!fetchErr.empty()) {
|
||||
result.error = std::format("git fetch for '{}': {}", name, fetchErr);
|
||||
return result;
|
||||
if (std::string err = FetchGit(dep.source, cloneDir); !err.empty()) {
|
||||
return std::format("git fetch for '{}': {}", name, err);
|
||||
}
|
||||
if (dep.builder != ExternalBuilder::CMake) return "";
|
||||
|
||||
if (cancelled.load(std::memory_order_relaxed)) return result;
|
||||
|
||||
fs::path cmakeBuildDir;
|
||||
if (dep.builder == ExternalBuilder::CMake) {
|
||||
cmakeBuildDir = cloneDir / "build";
|
||||
if (std::string err = ConfigureCMake(cloneDir, cmakeBuildDir, dep.options, target); !err.empty()) {
|
||||
result.error = std::format("cmake configure for '{}': {}", name, err);
|
||||
return result;
|
||||
}
|
||||
if (cancelled.load(std::memory_order_relaxed)) return result;
|
||||
if (std::string err = BuildCMake(cmakeBuildDir); !err.empty()) {
|
||||
result.error = std::format("cmake build for '{}': {}", name, err);
|
||||
return result;
|
||||
}
|
||||
const fs::path cmakeBuildDir = ExternalBuildDir(dep, target);
|
||||
if (std::string err = ConfigureCMake(cloneDir, cmakeBuildDir, dep.options, target); !err.empty()) {
|
||||
return std::format("cmake configure for '{}': {}", name, err);
|
||||
}
|
||||
if (std::string err = BuildCMake(cmakeBuildDir); !err.empty()) {
|
||||
return std::format("cmake build for '{}': {}", name, err);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
ExternalBuildResult Crafter::ExternalOutputs(const ExternalDependency& dep, std::string_view target) {
|
||||
ExternalBuildResult result;
|
||||
result.compileFlags = ExternalIncludeFlags(dep, target);
|
||||
|
||||
if (dep.builder == ExternalBuilder::CMake) {
|
||||
// Each search path gets both a -L (link-time) and a -Wl,-rpath
|
||||
// (runtime-loader). The rpath stays embedded in the produced
|
||||
// binary so it picks up shared deps from the cache without any
|
||||
// LD_LIBRARY_PATH gymnastics. Static deps (.a) ignore the rpath
|
||||
// harmlessly. The PE/COFF target (mingw) resolves DLLs via PATH or
|
||||
// adjacency rather than rpath, so lld warns if we pass it.
|
||||
const bool isPe = target == "x86_64-w64-mingw32";
|
||||
std::string buildDirAbs = fs::absolute(cmakeBuildDir).string();
|
||||
result.linkFlags.push_back(std::format("-L{}", buildDirAbs));
|
||||
if (!isPe) result.linkFlags.push_back(std::format("-Wl,-rpath,{}", buildDirAbs));
|
||||
for (const fs::path& libDir : dep.libDirs) {
|
||||
fs::path full = libDir.is_absolute() ? libDir : cmakeBuildDir / libDir;
|
||||
std::string fullAbs = fs::absolute(full).string();
|
||||
result.linkFlags.push_back(std::format("-L{}", fullAbs));
|
||||
if (!isPe) result.linkFlags.push_back(std::format("-Wl,-rpath,{}", fullAbs));
|
||||
}
|
||||
for (const std::string& lib : dep.libs) {
|
||||
result.linkFlags.push_back(std::format("-l{}", lib));
|
||||
}
|
||||
const fs::path cmakeBuildDir = ExternalBuildDir(dep, target);
|
||||
if (dep.builder != ExternalBuilder::CMake || cmakeBuildDir.empty()) return result;
|
||||
|
||||
// The newest library in every directory the link searches: the build
|
||||
// directory and the libDirs, where projects like msquic put theirs,
|
||||
// versioned names (libmsquic.so.2) included.
|
||||
auto isLibrary = [](const std::string& name) {
|
||||
return name.ends_with(".a") || name.ends_with(".so") || name.contains(".so.") || name.ends_with(".dll");
|
||||
};
|
||||
std::vector<fs::path> searched = { cmakeBuildDir };
|
||||
for (const fs::path& libDir : dep.libDirs) searched.push_back(libDir.is_absolute() ? libDir : cmakeBuildDir / libDir);
|
||||
for (const fs::path& dir : searched) {
|
||||
std::error_code listEc;
|
||||
for (fs::directory_iterator it(dir, listEc), end; !listEc && it != end; it.increment(listEc)) {
|
||||
std::error_code ec;
|
||||
if (!it->is_regular_file(ec) || !isLibrary(it->path().filename().string())) continue;
|
||||
fs::file_time_type t = it->last_write_time(ec);
|
||||
if (!ec && t > result.latestArtifact) result.latestArtifact = t;
|
||||
}
|
||||
// Each search path gets both a -L (link-time) and a -Wl,-rpath
|
||||
// (runtime-loader). The rpath stays embedded in the produced
|
||||
// binary so it picks up shared deps from the cache without any
|
||||
// LD_LIBRARY_PATH gymnastics. Static deps (.a) ignore the rpath
|
||||
// harmlessly. The PE/COFF target (mingw) resolves DLLs via PATH or
|
||||
// adjacency rather than rpath, so lld warns if we pass it.
|
||||
const bool isPe = target == "x86_64-w64-mingw32";
|
||||
std::string buildDirAbs = fs::absolute(cmakeBuildDir).string();
|
||||
result.linkFlags.push_back(std::format("-L{}", buildDirAbs));
|
||||
if (!isPe) result.linkFlags.push_back(std::format("-Wl,-rpath,{}", buildDirAbs));
|
||||
for (const fs::path& libDir : dep.libDirs) {
|
||||
fs::path full = libDir.is_absolute() ? libDir : cmakeBuildDir / libDir;
|
||||
std::string fullAbs = fs::absolute(full).string();
|
||||
result.linkFlags.push_back(std::format("-L{}", fullAbs));
|
||||
if (!isPe) result.linkFlags.push_back(std::format("-Wl,-rpath,{}", fullAbs));
|
||||
}
|
||||
for (const std::string& lib : dep.libs) {
|
||||
result.linkFlags.push_back(std::format("-l{}", lib));
|
||||
}
|
||||
|
||||
// The newest library in every directory the link searches: the build
|
||||
// directory and the libDirs, where projects like msquic put theirs,
|
||||
// versioned names (libmsquic.so.2) included.
|
||||
auto isLibrary = [](const std::string& name) {
|
||||
return name.ends_with(".a") || name.ends_with(".so") || name.contains(".so.") || name.ends_with(".dll");
|
||||
};
|
||||
std::vector<fs::path> searched = { cmakeBuildDir };
|
||||
for (const fs::path& libDir : dep.libDirs) searched.push_back(libDir.is_absolute() ? libDir : cmakeBuildDir / libDir);
|
||||
for (const fs::path& dir : searched) {
|
||||
std::error_code listEc;
|
||||
for (fs::directory_iterator it(dir, listEc), end; !listEc && it != end; it.increment(listEc)) {
|
||||
std::error_code ec;
|
||||
if (!it->is_regular_file(ec) || !isLibrary(it->path().filename().string())) continue;
|
||||
fs::file_time_type t = it->last_write_time(ec);
|
||||
if (!ec && t > result.latestArtifact) result.latestArtifact = t;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue