Skip to content

Commit c313c02

Browse files
authored
fix: keep multi-target executables independent of sibling shared targets (#761)
Retain executable-owned objects and declared shared dependencies in workspace and artifact plans, omit host-only shared products from consumers, and cover artifact/tool and dual-role providers. Closes #760.
1 parent acd9578 commit c313c02

4 files changed

Lines changed: 245 additions & 9 deletions

File tree

‎src/build/plan.cppm‎

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2247,6 +2247,9 @@ make_plan(const mcpp::manifest::Manifest& manifest,
22472247
for (std::size_t i = 1; i < packages.size(); ++i) {
22482248
auto const& p = packages[i];
22492249
auto qname = qualified_package_name(p.manifest);
2250+
// Host-only packages are built by their own sub-build. Clearing their
2251+
// source globs does not remove the declared shared targets.
2252+
if (p.buildTimeOnly) continue;
22502253
// A DISTRIBUTION PACKAGE's shared target is already built — that is what
22512254
// the package IS. Creating a link unit for it made ninja fail outright
22522255
// with `multiple rules generate bin/libmathkit.dll`: this loop declared
@@ -2870,9 +2873,11 @@ make_plan(const mcpp::manifest::Manifest& manifest,
28702873
}
28712874
}
28722875
}
2876+
// An artifact is an independent program of its package, even when
2877+
// that package also provides a shared image to other consumers.
28732878
for (auto const& cu : plan.compileUnits) {
28742879
if (!closure.contains(cu.packageName)) continue;
2875-
if (sharedDepPackages.contains(cu.packageName)) continue;
2880+
if (cu.packageName != owner && sharedDepPackages.contains(cu.packageName)) continue;
28762881
if (mcpp::links_unconditionally(cu.kind)) lu.objects.push_back(cu.object);
28772882
}
28782883
if (!r.target.main.empty()) {
@@ -2920,13 +2925,20 @@ make_plan(const mcpp::manifest::Manifest& manifest,
29202925
}
29212926
for (auto const& cu : plan.compileUnits) {
29222927
if (!closure.contains(cu.packageName)) continue;
2923-
if (sharedDepPackages.contains(cu.packageName)) continue;
2928+
if (cu.packageName != owner && sharedDepPackages.contains(cu.packageName)) continue;
29242929
if (!is_implementation_source(cu.kind)) continue;
29252930
if (lu.entryMain && cu.source == *lu.entryMain) continue;
29262931
if (entryFilesAcrossTargets.contains(cu.source)) continue;
29272932
lu.objects.push_back(cu.object);
29282933
}
2929-
append_shared_deps_for_linked_objects(lu);
2934+
// Only this program's closure contributes shared links. Walking every
2935+
// compile unit also picked up consumers of its sibling shared image
2936+
// and made the artifact depend on that image instead of its own code.
2937+
for (auto i : seen)
2938+
if (i == r.packageIndex
2939+
|| (!sharedDepPackages.contains(qualified_package_name(packages[i].manifest))
2940+
&& !placedInImage.contains(qualified_package_name(packages[i].manifest))))
2941+
append_direct_shared_deps(lu, i);
29302942
// In a workspace plan the plan's own line pools the dependencies'
29312943
// flags and not a member's, so the program links with its closure's
29322944
// line, as a member's program does, in a group that places nothing.
@@ -3087,9 +3099,12 @@ make_plan(const mcpp::manifest::Manifest& manifest,
30873099
"'{}', which target '{}' of this build also produces",
30883100
t.name, owner, lu.output.generic_string(), other.targetName));
30893101

3102+
// A sibling shared target is another image of this member's
3103+
// objects, not a dependency of its executable. Only another
3104+
// package's shared target supplies its implementation by link.
30903105
for (auto const& cu : plan.compileUnits) {
30913106
if (!closure.contains(cu.packageName)) continue;
3092-
if (sharedDepPackages.contains(cu.packageName)) continue;
3107+
if (cu.packageName != owner && sharedDepPackages.contains(cu.packageName)) continue;
30933108
if (mcpp::links_unconditionally(cu.kind)) lu.objects.push_back(cu.object);
30943109
}
30953110
if (!t.main.empty() && lu.kind != LinkUnit::StaticLibrary) {
@@ -3147,7 +3162,7 @@ make_plan(const mcpp::manifest::Manifest& manifest,
31473162
const bool entryDefinesMain = lu.entryMain && source_defines_main(*lu.entryMain);
31483163
for (auto const& cu : plan.compileUnits) {
31493164
if (!closure.contains(cu.packageName)) continue;
3150-
if (sharedDepPackages.contains(cu.packageName)) continue;
3165+
if (cu.packageName != owner && sharedDepPackages.contains(cu.packageName)) continue;
31513166
if (!is_implementation_source(cu.kind)) continue;
31523167
if (lu.entryMain && cu.source == *lu.entryMain) continue;
31533168
if (entryFilesAcrossTargets.contains(cu.source)) continue;
@@ -3159,11 +3174,12 @@ make_plan(const mcpp::manifest::Manifest& manifest,
31593174
}
31603175
if (lu.kind != LinkUnit::StaticLibrary) {
31613176
const auto before = lu.implicitInputs.size();
3162-
// The shared libraries the closure's packages link, and no
3163-
// other member's.
3177+
// The member still links its declared shared dependencies
3178+
// even when it also produces a shared target of its own.
31643179
for (auto i : closureIdx)
3165-
if (!sharedDepPackages.contains(qualified_package_name(packages[i].manifest))
3166-
&& !placedInImage.contains(qualified_package_name(packages[i].manifest)))
3180+
if (i == mi
3181+
|| (!sharedDepPackages.contains(qualified_package_name(packages[i].manifest))
3182+
&& !placedInImage.contains(qualified_package_name(packages[i].manifest))))
31673183
append_direct_shared_deps(lu, i);
31683184
// The graph-built shared libraries this unit loads are placed
31693185
// beside it.

‎src/build/prepare/features.cpp‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1319,6 +1319,7 @@ step6_host_module_registration(PrepareState& state) {
13191319
// its sources would turn that gap into an undefined
13201320
// reference a long way from here.
13211321
if (viaTarget[d] || !viaAny[d]) continue;
1322+
state.packages[d].buildTimeOnly = true;
13221323
auto& dm = state.packages[d].manifest;
13231324
dm.buildConfig.sources.clear();
13241325
dm.buildConfig.featureSources.clear();

‎src/modgraph/scanner.cppm‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,9 @@ struct PackageRoot {
141141
UsageRequirements publicUsage;
142142
UsageRequirements linkUsage;
143143
bool usageResolved = false;
144+
// Reached only through build-time edges: resolved for host tools/rules,
145+
// but neither its sources nor its products belong to the target plan.
146+
bool buildTimeOnly = false;
144147
// A selected workspace member that the plan builds (workspace design
145148
// 2026-09-29 §15), and the directory below `bin/` its products are placed
146149
// in (empty: `bin/` itself). False on the plan's root and on every
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
#!/usr/bin/env bash
2+
# requires:
3+
# 880 -- mcpp#760: a member with shared and bin targets builds an independent
4+
# executable from its own implementation, not from its sibling shared library.
5+
#
6+
# A module interface, a separate implementation unit and a static dependency
7+
# must all reach the executable. Another member still consumes the shared
8+
# target. Workspace selection, shipped artifacts and host tools keep this rule.
9+
set -e
10+
source "$(dirname "$0")/_host_path.sh"
11+
12+
TMP=$(mktemp -d)
13+
trap 'rm -rf "$TMP"' EXIT
14+
fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; }
15+
MCPP="${MCPP:-mcpp}"
16+
17+
# Share the installed tool payloads, but keep the build and package caches
18+
# private. The fixture has no registry package dependencies; the default host
19+
# toolchain is resolved through the existing registry, as in the E2E runner.
20+
REGISTRY_HOST=$(host_path "${MCPP_HOME:-$HOME/.mcpp}/registry")
21+
export MCPP_HOME="$TMP/mcpp-home"
22+
mkdir -p "$MCPP_HOME" "$TMP/ws"
23+
cat > "$MCPP_HOME/config.toml" <<EOF
24+
[xlings]
25+
home = "$REGISTRY_HOST"
26+
EOF
27+
28+
EXE=""
29+
case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) EXE=".exe" ;; esac
30+
bin_of() { find target -path '*/bin/*' -name "$1$EXE" -type f | head -1; }
31+
32+
cd "$TMP/ws"
33+
cat > mcpp.toml <<'EOF'
34+
[workspace]
35+
members = ["dual", "client", "support"]
36+
EOF
37+
mkdir -p common/src dual/src client/src support/src
38+
cat > support/mcpp.toml <<'EOF'
39+
[package]
40+
name = "support"
41+
version = "0.1.0"
42+
43+
[targets.support]
44+
kind = "shared"
45+
EOF
46+
printf 'export module t880_support;\nexport int delta() { return 1; }\n' > support/src/support.cppm
47+
cat > common/mcpp.toml <<'EOF'
48+
[package]
49+
name = "common"
50+
version = "0.1.0"
51+
52+
[targets.common]
53+
kind = "lib"
54+
EOF
55+
printf 'export module t880_base;\nexport int base_value() { return 41; }\n' > common/src/base.cppm
56+
cat > dual/mcpp.toml <<'EOF'
57+
[package]
58+
name = "dual"
59+
version = "0.1.0"
60+
61+
[dependencies]
62+
common = { path = "../common" }
63+
support = { path = "../support" }
64+
65+
[targets.dual_dll]
66+
kind = "shared"
67+
68+
[targets.dual]
69+
kind = "bin"
70+
main = "src/main.cpp"
71+
EOF
72+
printf 'export module t880_dual;\nexport int interface_value() { return 1; }\nexport int answer();\n' > dual/src/dual.cppm
73+
printf 'module t880_dual;\nimport t880_base;\nimport t880_support;\nint answer() { return base_value() + delta(); }\n' > dual/src/impl.cpp
74+
printf 'import t880_dual;\nint main() { return answer() == 42 && interface_value() == 1 ? 0 : 1; }\n' > dual/src/main.cpp
75+
cat > client/mcpp.toml <<'EOF'
76+
[package]
77+
name = "client"
78+
version = "0.1.0"
79+
80+
[dependencies]
81+
dual = { path = "../dual" }
82+
83+
[targets.client]
84+
kind = "bin"
85+
main = "src/main.cpp"
86+
EOF
87+
cp dual/src/main.cpp client/src/main.cpp
88+
89+
"$MCPP" build --workspace > workspace.log 2>&1 || fail "the workspace did not build" workspace.log
90+
dual=$(bin_of dual)
91+
client=$(bin_of client)
92+
[ -n "$dual" ] && [ -n "$client" ] || fail "a member's executable is missing" workspace.log
93+
"$dual" || fail "the owner's executable did not run"
94+
"$client" || fail "the shared-library consumer did not run"
95+
96+
# Remove every placement of the sibling library. A client that imports it must
97+
# fail, while the owner's executable must still run: mere success with both
98+
# products beside it would also accept the import-library workaround for #760.
99+
mkdir hidden
100+
n=0
101+
while IFS= read -r library; do
102+
n=$((n + 1))
103+
mv "$library" "hidden/$n"
104+
done < <(find target -path '*/bin/*' \( -name '*dual_dll.dll' -o -name '*dual_dll.so*' -o -name '*dual_dll.dylib' \))
105+
[ "$n" -gt 0 ] || fail "the sibling shared library was not built" workspace.log
106+
"$dual" || fail "the owner's executable depends on its sibling shared library"
107+
if "$client" > client.log 2>&1; then
108+
fail "the external consumer did not use the shared library" client.log
109+
fi
110+
111+
"$MCPP" build -p dual > selected.log 2>&1 || fail "the selected member did not build" selected.log
112+
"$(bin_of dual)" || fail "the selected member's executable did not run"
113+
114+
# Request the same executable as an artifact, rather than selecting its owner.
115+
# The feature gate must be activated by the dependency that requests it.
116+
cat >> dual/mcpp.toml <<'EOF'
117+
required_features = ["exe"]
118+
119+
[features]
120+
exe = []
121+
EOF
122+
mkdir -p shipped/src
123+
cat > shipped/mcpp.toml <<'EOF'
124+
[package]
125+
name = "shipped"
126+
version = "0.1.0"
127+
128+
[dependencies]
129+
dual = { path = "../dual", artifacts = ["dual"], features = ["exe"] }
130+
support = { path = "../support" }
131+
132+
[targets.shipped]
133+
kind = "bin"
134+
main = "src/main.cpp"
135+
EOF
136+
printf 'import t880_support;\nint main() { return delta() == 1 ? 0 : 1; }\n' > shipped/src/main.cpp
137+
cat > mcpp.toml <<'EOF'
138+
[workspace]
139+
members = ["dual", "client", "support", "shipped"]
140+
EOF
141+
"$MCPP" build -p shipped > artifact.log 2>&1 || fail "the requested artifact did not build" artifact.log
142+
artifact=$(find target -path "*/bin/shipped/dual$EXE" -type f | head -1)
143+
[ -n "$artifact" ] || fail "the requested artifact was not placed beside its consumer" artifact.log
144+
"$artifact" || fail "the requested artifact did not run"
145+
"$(bin_of shipped)" || fail "the artifact's consumer did not run"
146+
"$MCPP" build --workspace > artifact-workspace.log 2>&1 || fail "the workspace with an artifact request did not build" artifact-workspace.log
147+
"$artifact" || fail "the workspace's requested artifact did not run"
148+
"$dual" || fail "the workspace's owner executable did not run"
149+
n=0
150+
while IFS= read -r library; do
151+
n=$((n + 1))
152+
mv "$library" "hidden/artifact-$n"
153+
done < <(find target -path '*/bin/*' \( -name '*dual_dll.dll' -o -name '*dual_dll.so*' -o -name '*dual_dll.dylib' \))
154+
"$artifact" || fail "the requested artifact depends on its sibling shared library"
155+
156+
# A host tool is rooted at its own package and published separately. Use a
157+
# static dependency here: external shared-tool runtime deployment is a separate
158+
# contract, while this test checks independence from the tool's own sibling.
159+
mkdir -p "$TMP/tooldual/src" "$TMP/toolapp/src"
160+
cp dual/src/dual.cppm "$TMP/tooldual/src/dual.cppm"
161+
cp dual/src/main.cpp "$TMP/tooldual/src/main.cpp"
162+
printf 'module t880_dual;\nimport t880_base;\nint answer() { return base_value() + 1; }\n' > "$TMP/tooldual/src/impl.cpp"
163+
cat > "$TMP/tooldual/mcpp.toml" <<'EOF'
164+
[package]
165+
name = "tooldual"
166+
version = "0.1.0"
167+
168+
[dependencies]
169+
common = { path = "../ws/common" }
170+
171+
[features]
172+
exe = []
173+
174+
[targets.dual_dll]
175+
kind = "shared"
176+
177+
[targets.dual]
178+
kind = "bin"
179+
main = "src/main.cpp"
180+
required_features = ["exe"]
181+
EOF
182+
cat > "$TMP/toolapp/mcpp.toml" <<'EOF'
183+
[package]
184+
name = "toolapp"
185+
version = "0.1.0"
186+
187+
[build-dependencies]
188+
tooldual = { path = "../tooldual", tools = ["dual"], features = ["exe"] }
189+
EOF
190+
printf 'int main() { return 0; }\n' > "$TMP/toolapp/src/main.cpp"
191+
cat > "$TMP/toolapp/build.mcpp" <<'EOF'
192+
#include <cstdlib>
193+
#include <string>
194+
import mcpp;
195+
int main() {
196+
const char* tool = mcpp::dep_bin("tooldual", "dual");
197+
if (!tool || !*tool) return 1;
198+
return std::system((std::string("\"") + tool + "\"").c_str()) == 0 ? 0 : 1;
199+
}
200+
EOF
201+
cd "$TMP/toolapp"
202+
"$MCPP" build > tool.log 2>&1 || fail "the host tool did not build and run" tool.log
203+
"$(bin_of toolapp)" || fail "the host tool's consumer did not run"
204+
# A target-side edge must retain the same provider's shared product even when
205+
# it is also requested as a build-time tool.
206+
cat >> mcpp.toml <<'EOF'
207+
208+
[dependencies]
209+
tooldual = { path = "../tooldual" }
210+
EOF
211+
cp "$TMP/tooldual/src/main.cpp" src/main.cpp
212+
"$MCPP" build > dual-role.log 2>&1 || fail "the dual-role provider did not build" dual-role.log
213+
"$(bin_of toolapp)" || fail "the dual-role provider's consumer did not run"
214+
[ -n "$(find target -path '*/bin/*' \( -name '*dual_dll.dll' -o -name '*dual_dll.so*' -o -name '*dual_dll.dylib' \) -type f | head -1)" ] || fail "the dual-role provider's shared library is missing" dual-role.log
215+
216+
echo "PASS: 880_a_members_executable_keeps_its_own_implementation"

0 commit comments

Comments
 (0)