Crafter.Build/tests/ConcurrentDependencyReset/main.cpp
Jorijn van der Graaf 8892154b28
Some checks failed
CI / build-test-release (push) Failing after 7m15s
linting
2026-07-23 01:24:42 +02:00

125 lines
5.5 KiB
C++

// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
import std;
import Crafter.Build;
namespace fs = std::filesystem;
using namespace Crafter;
// Regression for the concurrent dependency-reset deadlock (issue #16).
//
// Build() resets each Module/ModulePartition's per-build `compiled` /`checked`
// flags so a reused Configuration re-evaluates mtimes. That reset MUST stay
// scoped to the configuration being built. It used to recurse into
// cfg.dependencies — but dependency Configurations are shared across the build
// DAG and each is compiled concurrently by its own Build() call. A parent's
// recursive reset could therefore clear a dependency's module `compiled` flag
// *after* that dependency's module-compile thread had set it true and exited,
// but before an intra-config waiter (its impl / a dependent partition) ran
// `compiled.wait(false)`. The waiter then blocked forever on a flag nothing
// re-signalled: the build froze mid-compile, idle, with no compiler process
// alive.
//
// This test pins the invariant deterministically, without depending on thread
// timing: build a static-lib dependency fully (its module ends up compiled),
// then build a consumer that depends on it while the dependency is already
// cached in depResults (so it is NOT rebuilt). Before the fix, the consumer's
// recursive reset flipped the dependency's module `compiled` flag back to false
// and, because the dependency never rebuilds, it stayed false. After the fix
// the consumer resets only its own modules, so the dependency's flag is left
// intact. We assert it is still set once the consumer build completes.
int main() {
fs::path fixtureRoot = fs::current_path() / "tests" / "ConcurrentDependencyReset" / "fixture";
// Cold output: a stale archive/PCM from a previous run would let the
// dependency build short-circuit and skip the module compile we rely on.
std::error_code ec;
fs::remove_all(fixtureRoot / "deplib" / "build", ec);
fs::remove_all(fixtureRoot / "deplib" / "bin", ec);
fs::remove_all(fixtureRoot / "app" / "build", ec);
fs::remove_all(fixtureRoot / "app" / "bin", ec);
Configuration dep;
dep.path = fixtureRoot / "deplib";
dep.name = "depmod";
dep.outputName = "depmod";
dep.target = HostTarget();
dep.type = ConfigurationType::LibraryStatic;
{
std::array<fs::path, 1> ifaces = { "DepMod" };
std::array<fs::path, 1> impls = { "DepMod-impl" };
dep.GetInterfacesAndImplementations(ifaces, impls);
}
if (dep.interfaces.size() != 1) {
std::println(std::cerr, "expected 1 dependency interface, got {}", dep.interfaces.size());
return 1;
}
{
std::unordered_map<fs::path, std::shared_future<BuildResult>> depResults;
std::mutex depMutex;
BuildResult r = Build(dep, depResults, depMutex);
if (!r.result.empty()) {
std::println(std::cerr, "dependency build failed: {}", r.result);
return 1;
}
if (!dep.interfaces[0]->compiled.load()) {
std::println(std::cerr, "dependency module not marked compiled after its own build");
return 1;
}
// Seed a consumer-facing cache so the consumer reuses this build instead
// of rebuilding the dependency — mirrors how a shared dep is built once
// and consumed by multiple parents through depResults.
std::promise<BuildResult> promise;
promise.set_value(std::move(r));
std::shared_future<BuildResult> ready = promise.get_future().share();
std::unordered_map<fs::path, std::shared_future<BuildResult>> consumerDeps;
consumerDeps.emplace(dep.PcmDir(), ready);
Configuration app;
app.path = fixtureRoot / "app";
app.name = "resetapp";
app.outputName = "resetapp";
app.target = HostTarget();
app.type = ConfigurationType::Executable;
app.dependencies = { &dep };
{
std::array<fs::path, 0> appIfaces = {};
std::array<fs::path, 1> appImpls = { "main" };
app.GetInterfacesAndImplementations(appIfaces, appImpls);
}
BuildResult ar = Build(app, consumerDeps, depMutex);
if (!ar.result.empty()) {
std::println(std::cerr, "consumer build failed: {}", ar.result);
return 1;
}
// The core regression assertion: building the consumer must not have
// reset the (cached, never-rebuilt) dependency's module flag. Before the
// fix this was false and an intra-config waiter would have deadlocked.
if (!dep.interfaces[0]->compiled.load()) {
std::println(std::cerr, "FAIL: consumer build reset a cached dependency's module 'compiled' " "flag (issue #16 regression) — this is the state that deadlocks a " "concurrent build");
return 1;
}
fs::path bin = app.BinDir() / "resetapp";
if (!fs::exists(bin)) {
std::println(std::cerr, "consumer binary not produced at {}", bin.string());
return 1;
}
auto run = RunCommandWithTimeout(bin.string(), std::chrono::seconds(10));
if (run.exitCode != 0 || run.timedOut || run.crashed) {
std::println(std::cerr, "consumer exe did not exit cleanly: exit={} output={}", run.exitCode, run.output);
return 1;
}
if (run.output != "42") {
std::println(std::cerr, "expected '42', got '{}'", run.output);
return 1;
}
}
return 0;
}