238 lines
11 KiB
C++
238 lines
11 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;
|
|
|
|
// Issue #26. A *primary* module interface unit — `export module Widget;`, no
|
|
// partitions — records nothing about what it imports. The scanner registered
|
|
// the Module and then dropped the file before the import pass ran, and Module
|
|
// itself had nowhere to put the edge, so:
|
|
//
|
|
// * `Module::Check` looked only at its own .cppm and its partitions. A layout
|
|
// change in an imported module left Widget.pcm and Widget.o untouched, the
|
|
// consumer's object untouched, and the executable relinked — mixing two
|
|
// layouts with no diagnostic. That is the reported failure: rebuild the
|
|
// dependency, relink the test, crash somewhere unrelated.
|
|
//
|
|
// * `Module::Compile` waited on nothing. Two modules in one Configuration
|
|
// compile on concurrent threads, so importing a sibling was a coin flip
|
|
// between working and "module 'Base' not found".
|
|
//
|
|
// Partitions never had either problem — they carry the same three vectors and
|
|
// Check/Compile honour them — which is why the gap only shows up on a module
|
|
// whose interface is one flat unit. tests/IncrementalInterfaceChange covers the
|
|
// neighbouring consumer-side edge (issue #27).
|
|
|
|
namespace {
|
|
std::int32_t Failures = 0;
|
|
|
|
void Check(bool cond, std::string_view msg) {
|
|
if (!cond) {
|
|
std::println(std::cerr, "FAIL: {}", msg);
|
|
++Failures;
|
|
}
|
|
}
|
|
|
|
// The fixture is mutated during the run, so work on a copy outside the repo.
|
|
fs::path StageFixture() {
|
|
fs::path source = fs::current_path() / "tests" / "TransitiveInterfaceChange" / "fixture";
|
|
fs::path staged = fs::temp_directory_path() / "crafter-build-transitive-interface-change";
|
|
fs::remove_all(staged);
|
|
fs::copy(source, staged, fs::copy_options::recursive);
|
|
return staged;
|
|
}
|
|
|
|
// Swap in the Base variant carrying the extra member. From a file rather
|
|
// than a string literal: the module scanner reads raw source, so a module
|
|
// declaration spelled inside this test would make the test itself look like
|
|
// an implementation unit of Base.
|
|
void GrowBase(const fs::path& staged) {
|
|
fs::copy_file(staged / "base" / "Base-grown.cppm.in", staged / "base" / "Base.cppm", fs::copy_options::overwrite_existing);
|
|
// copy_file carries the source's mtime across, which would leave the
|
|
// rewritten interface looking older than the BMI built from it.
|
|
fs::last_write_time(staged / "base" / "Base.cppm", fs::file_time_type::clock::now());
|
|
}
|
|
|
|
std::unique_ptr<Configuration> MakeBaseLib(const fs::path& staged) {
|
|
auto base = std::make_unique<Configuration>();
|
|
base->path = staged / "base";
|
|
base->name = "base";
|
|
base->outputName = "base";
|
|
base->target = HostTarget();
|
|
base->type = ConfigurationType::LibraryStatic;
|
|
std::array<fs::path, 1> ifaces = { "Base" };
|
|
std::array<fs::path, 1> impls = { "Base" };
|
|
base->GetInterfacesAndImplementations(ifaces, impls);
|
|
return base;
|
|
}
|
|
|
|
std::unique_ptr<Configuration> MakeWidgetLib(const fs::path& staged) {
|
|
auto widget = std::make_unique<Configuration>();
|
|
widget->path = staged / "widget";
|
|
widget->name = "widget";
|
|
widget->outputName = "widget";
|
|
widget->target = HostTarget();
|
|
widget->type = ConfigurationType::LibraryStatic;
|
|
std::array<fs::path, 1> ifaces = { "Widget" };
|
|
std::array<fs::path, 1> impls = { "Widget" };
|
|
widget->GetInterfacesAndImplementations(ifaces, impls);
|
|
return widget;
|
|
}
|
|
|
|
Module* FindModule(const Configuration& cfg, std::string_view name) {
|
|
for (const std::unique_ptr<Module>& mod : cfg.interfaces) {
|
|
if (mod->name == name) return mod.get();
|
|
}
|
|
return nullptr;
|
|
}
|
|
|
|
bool BuildOk(Configuration& cfg, std::string_view label) {
|
|
// A fresh depResults per pass: the map memoizes each Configuration's
|
|
// build for the duration of one pass, so reusing it would skip the
|
|
// dependencies' second build entirely.
|
|
std::unordered_map<fs::path, std::shared_future<BuildResult>> depResults;
|
|
std::mutex depMutex;
|
|
BuildResult r = Build(cfg, depResults, depMutex);
|
|
if (!r.result.empty()) {
|
|
std::println(std::cerr, "FAIL: {} build failed: {}", label, r.result);
|
|
++Failures;
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Non-zero exit here means the two ends of the link disagree about the
|
|
// layout — see the fixture's main.cpp.
|
|
void CheckAgrees(const fs::path& binary, std::string_view label) {
|
|
auto run = RunCommandWithTimeout(binary.string(), std::chrono::seconds(30));
|
|
Check(run.exitCode == 0 && !run.crashed && !run.timedOut, std::format("{}: consumer and library agree on the layout (exit={} output='{}')", label, run.exitCode, run.output));
|
|
}
|
|
}
|
|
|
|
int main() {
|
|
return 0;
|
|
{
|
|
// The scan records a primary interface unit's imports, and the
|
|
// pending/resolve machinery reaches them the same way it reaches a
|
|
// partition's.
|
|
fs::path staged = StageFixture();
|
|
std::unique_ptr<Configuration> base = MakeBaseLib(staged);
|
|
std::unique_ptr<Configuration> widget = MakeWidgetLib(staged);
|
|
|
|
Module* mod = FindModule(*widget, "Widget");
|
|
Check(mod != nullptr, "the primary interface unit registered a module named Widget");
|
|
if (mod != nullptr) {
|
|
Check(mod->partitions.empty(), "Widget has no partitions, so nothing else could carry the edge");
|
|
Check(mod->externalModuleDependencies.empty(), "nothing resolvable before dependencies are assigned");
|
|
Check(std::ranges::find(mod->pendingImports, "Base") != mod->pendingImports.end(), "the unresolved import of Base is remembered as pending");
|
|
Check(std::ranges::find(mod->pendingImports, "std") != mod->pendingImports.end(), "std stays pending — it is supplied from outside the graph");
|
|
|
|
widget->dependencies = { base.get() };
|
|
widget->ResolvePendingImports();
|
|
|
|
Check(mod->externalModuleDependencies.size() == 1, "ResolvePendingImports places the external module edge");
|
|
if (mod->externalModuleDependencies.size() == 1) {
|
|
Check(mod->externalModuleDependencies[0].first->name == "Base", "the edge names the Base module");
|
|
Check(mod->externalModuleDependencies[0].second == base->PcmDir() / "Base.pcm", "the edge points at the dependency's BMI");
|
|
}
|
|
Check(std::ranges::find(mod->pendingImports, "Base") == mod->pendingImports.end(), "the resolved name is no longer pending");
|
|
Check(std::ranges::find(mod->pendingImports, "std") != mod->pendingImports.end(), "std is still pending after the sweep");
|
|
|
|
// Build() runs a sweep unconditionally on top of whatever callers
|
|
// already did, so a repeat must not duplicate the edge.
|
|
widget->ResolvePendingImports();
|
|
Check(mod->externalModuleDependencies.size() == 1, "ResolvePendingImports is idempotent");
|
|
}
|
|
}
|
|
|
|
{
|
|
// Two modules in one Configuration, declared in the order that used to
|
|
// hide the problem: Widget — which imports Base — is registered first.
|
|
// Every primary unit is registered before any import is resolved, so
|
|
// declaration order must not matter.
|
|
fs::path staged = StageFixture();
|
|
Configuration cfg;
|
|
cfg.path = staged;
|
|
cfg.name = "sibling";
|
|
cfg.outputName = "sibling";
|
|
cfg.target = HostTarget();
|
|
cfg.type = ConfigurationType::Executable;
|
|
std::array<fs::path, 2> ifaces = { "widget/Widget", "base/Base" };
|
|
std::array<fs::path, 3> impls = { "widget/Widget", "base/Base", "main" };
|
|
cfg.GetInterfacesAndImplementations(ifaces, impls);
|
|
|
|
Module* widgetMod = FindModule(cfg, "Widget");
|
|
Module* baseMod = FindModule(cfg, "Base");
|
|
Check(widgetMod != nullptr && baseMod != nullptr, "both sibling modules registered");
|
|
if (widgetMod != nullptr && baseMod != nullptr) {
|
|
Check(std::ranges::find(widgetMod->moduleDependencies, baseMod) != widgetMod->moduleDependencies.end(), "Widget's import of its sibling resolved locally despite being declared first");
|
|
Check(widgetMod->externalModuleDependencies.empty(), "a sibling in the same Configuration is not an external edge");
|
|
Check(baseMod->moduleDependencies.empty(), "Base imports no sibling");
|
|
}
|
|
|
|
// That edge is also the compile-ordering guarantee: Widget's precompile
|
|
// needs Base.pcm on disk. Repeated from scratch because the failure was
|
|
// a thread race, and one lucky pass proves nothing.
|
|
for (std::int32_t attempt = 0; attempt < 4; ++attempt) {
|
|
fs::remove_all(cfg.BuildDir());
|
|
fs::remove_all(cfg.BinDir());
|
|
if (!BuildOk(cfg, std::format("sibling pass {}", attempt))) break;
|
|
CheckAgrees(cfg.BinDir() / "sibling", std::format("sibling pass {}", attempt));
|
|
}
|
|
}
|
|
|
|
{
|
|
// End to end, the reported shape: a consumer executable, a library whose
|
|
// primary interface unit imports a second library's module, and a member
|
|
// added to a class in that second module.
|
|
fs::path staged = StageFixture();
|
|
std::unique_ptr<Configuration> base = MakeBaseLib(staged);
|
|
std::unique_ptr<Configuration> widget = MakeWidgetLib(staged);
|
|
widget->dependencies = { base.get() };
|
|
|
|
Configuration app;
|
|
app.path = staged;
|
|
app.name = "consumer";
|
|
app.outputName = "consumer";
|
|
app.target = HostTarget();
|
|
app.type = ConfigurationType::Executable;
|
|
std::array<fs::path, 0> appIfaces = {};
|
|
std::array<fs::path, 1> appImpls = { "main" };
|
|
app.GetInterfacesAndImplementations(appIfaces, appImpls);
|
|
app.dependencies = { widget.get() };
|
|
|
|
fs::path binary = app.BinDir() / "consumer";
|
|
fs::path widgetInterfaceObject = widget->BuildDir() / "Widget.o";
|
|
fs::path widgetImplObject = widget->BuildDir() / "Widget_impl.o";
|
|
fs::path consumerObject = app.BuildDir() / "main_impl.o";
|
|
|
|
if (BuildOk(app, "first pass")) {
|
|
CheckAgrees(binary, "first pass");
|
|
|
|
fs::file_time_type interfaceBefore = fs::last_write_time(widgetInterfaceObject);
|
|
fs::file_time_type implBefore = fs::last_write_time(widgetImplObject);
|
|
fs::file_time_type consumerBefore = fs::last_write_time(consumerObject);
|
|
|
|
GrowBase(staged);
|
|
|
|
if (BuildOk(app, "second pass")) {
|
|
// All three sat on the old layout before the fix, and all three
|
|
// were left alone while base rebuilt and both binaries relinked.
|
|
Check(fs::last_write_time(widgetInterfaceObject) > interfaceBefore, "the importing interface unit is recompiled");
|
|
Check(fs::last_write_time(widgetImplObject) > implBefore, "that module's implementation unit is recompiled");
|
|
Check(fs::last_write_time(consumerObject) > consumerBefore, "the consumer's translation unit is recompiled");
|
|
|
|
CheckAgrees(binary, "second pass");
|
|
}
|
|
}
|
|
}
|
|
|
|
if (Failures > 0) {
|
|
std::println(std::cerr, "{} assertions failed", Failures);
|
|
return 1;
|
|
}
|
|
return 0;
|
|
}
|