Skip to content

Commit 30b900f

Browse files
fix(build): every compiler of a build belongs to its generation
Killing the mcpp that holds a build directory left its compilers writing there while the next build entered: 2026.11.5.1 failed 3 of 75 such rounds under the default schedule and 12 of 15 under the split schedule; the split schedule's generations covered only its own compilers. Every strategy now caps its compilers, so every compile edge runs through the capped executor, registers in the build's generation, and the next build enters only after those compilers end. The compile database keeps the compiler's own argv. E2E 1016 kills the holding build twelve times under each schedule; it passes on this change and fails on 2026.11.5.1 (split, round 1). The executor costs about 1 ms per edge on Linux. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com>
1 parent 4e01eb8 commit 30b900f

5 files changed

Lines changed: 88 additions & 5 deletions

File tree

‎src/build/execute.cppm‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2177,7 +2177,14 @@ std::optional<int> run_ninja_fast(const std::string& ninjaProgram,
21772177
}
21782178

21792179
std::optional<mcpp::build::schedule::detach::RunScope> compilerRun;
2180-
if (mcpp::build::read_schedule(ninjaPath) == "detach-codegen") {
2180+
// A graph whose compilers run through the capped executor replays inside a
2181+
// generation, as its first drive did.
2182+
const bool capped = [&] {
2183+
std::ifstream in(ninjaPath, std::ios::binary);
2184+
const std::string text{std::istreambuf_iterator<char>(in), {}};
2185+
return text.find(" __compiler-cap ") != std::string::npos;
2186+
}();
2187+
if (capped) {
21812188
auto opened = mcpp::build::schedule::detach::RunScope::begin(outputDir);
21822189
if (!opened) { mcpp::ui::error(opened.error()); return 1; }
21832190
compilerRun.emplace(std::move(*opened));

‎src/build/ninja_backend.cppm‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1472,7 +1472,9 @@ std::string emit_ninja_string(const BuildPlan& plan, std::string* placements,
14721472
// Ninja has extra slots for BMI publication and join edges. Ordinary
14731473
// compilers must share the supervisors' semaphore rather than use that
14741474
// larger slot count as their own concurrency bound.
1475-
const std::string compilerPrefix = splitBmi
1475+
// Every capped graph (2026.11.7.1): the executor also registers each
1476+
// compiler as a participant of the build's generation.
1477+
const std::string compilerPrefix = plan.scheduleCompilerCap > 0
14761478
? std::format("$mcpp __compiler-cap --sem .mcpp-sched --cap {} --output $out --depfile $out.d -- ", plan.scheduleCompilerCap)
14771479
: std::string{};
14781480
// The other split shape. Clang publishes no BMI early — it writes the BMI
@@ -4542,13 +4544,12 @@ std::expected<BuildResult, BuildError> NinjaBackend::build(const BuildPlan& plan
45424544
plan.outputDir.string(), ec.message()),
45434545
plan.outputDir});
45444546
std::optional<mcpp::build::schedule::detach::RunScope> compilerRun;
4545-
if (!opts.dryRun && plan.scheduleTag == "detach-codegen") {
4547+
if (!opts.dryRun && plan.scheduleCompilerCap > 0) {
45464548
auto opened = mcpp::build::schedule::detach::RunScope::begin(plan.outputDir);
45474549
if (!opened) return std::unexpected(BuildError{opened.error(), plan.outputDir});
45484550
compilerRun.emplace(std::move(*opened));
4549-
}
4550-
if (!opts.dryRun && plan.scheduleTag == "detach-codegen")
45514551
reclaim_stale_schedule_tokens(plan.outputDir);
4552+
}
45524553

45534554
auto ninja_path = plan.outputDir / "build.ninja";
45544555
// Written beside build.ninja and not into it: the fast path replays the

‎src/build/schedule/policy.cppm‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,11 +178,27 @@ int resolve_jobs(const manifest::Manifest& m,
178178
// peaks near a gigabyte.
179179
Decision decide(const toolchain::Toolchain& tc, std::string_view requested, int hostJobs,
180180
int autoJobs = 0);
181+
// The module-edge shape alone, before the generation's compiler cap is applied.
182+
Decision decide_shape(const toolchain::Toolchain& tc, std::string_view requested, int hostJobs,
183+
int autoJobs = 0);
181184

182185
// ---------------------------------------------------------------------------
183186

184187
Decision decide(const toolchain::Toolchain& tc, std::string_view requested, int hostJobs,
185188
int autoJobs) {
189+
Decision d = decide_shape(tc, requested, hostJobs, autoJobs);
190+
// EVERY COMPILER OF A BUILD IS A PARTICIPANT OF ITS GENERATION (2026.11.7.1).
191+
// Under every strategy a compile edge runs through the capped executor, so a
192+
// later build does not enter the build directory while a compiler of an
193+
// earlier one -- whose mcpp was killed -- still writes there (design
194+
// 2026-11-07 §4.1.2). The cap is the strategy's, else the job count, else
195+
// what this machine would choose.
196+
if (d.compilerCap <= 0) d.compilerCap = hostJobs > 0 ? hostJobs : autoJobs;
197+
return d;
198+
}
199+
200+
Decision decide_shape(const toolchain::Toolchain& tc, std::string_view requested, int hostJobs,
201+
int autoJobs) {
186202
Decision d;
187203
const int cap = hostJobs > 0 ? hostJobs : 0;
188204

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
#!/usr/bin/env bash
2+
# requires: gcc
3+
# A BUILD WHOSE mcpp IS KILLED DOES NOT RACE THE NEXT ONE (2026.11.7.1, design
4+
# 2026-11-07 §4.1.2). Killing the mcpp that holds the build directory leaves
5+
# its compilers running. The next build waited only for the directory lock, so
6+
# it entered while those compilers still wrote BMIs and objects: 2026.11.5.1
7+
# failed 3 of 75 such rounds under the default schedule and 12 of 15 under the
8+
# split schedule. Every compiler is now a participant of its build's
9+
# generation, and the next build enters only after they end. Both schedules,
10+
# twelve rounds each: the waiting build succeeds and the build after it does
11+
# nothing.
12+
set -e
13+
MCPP="${MCPP:-mcpp}"
14+
TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT
15+
make_project() { # make_project <dir> <schedule>
16+
mkdir -p "$1/src"
17+
printf '[package]\nname = "kg"\nversion = "0.1.0"\nstandard = "c++23"\n[toolchain]\ndefault = "gcc@16.1.0"\n' > "$1/mcpp.toml"
18+
[ "$2" = split ] && printf '[build]\nbmi_schedule = "on"\n' >> "$1/mcpp.toml"
19+
for i in $(seq 1 24); do
20+
printf 'export module m%d;\nimport std;\nexport int f%d(){ std::vector<int> v(%d); return (int)v.size(); }\n' "$i" "$i" "$i" > "$1/src/m$i.cppm"
21+
done
22+
{ echo 'import std;'; for i in $(seq 1 24); do echo "import m$i;"; done
23+
echo 'int main(){ std::println("{}", f1()+f24()); }'; } > "$1/src/main.cpp"
24+
}
25+
for schedule in default split; do
26+
dir="$TMP/$schedule"; make_project "$dir" "$schedule"
27+
( cd "$dir" && "$MCPP" build > "$TMP/warm.log" 2>&1 ) || { cat "$TMP/warm.log"; exit 1; }
28+
for round in $(seq 1 12); do
29+
rm -rf "$dir/target"
30+
( cd "$dir" && exec "$MCPP" build > "$TMP/a.log" 2>&1 ) & a=$!
31+
( sleep 0.2; cd "$dir"; rc=0; "$MCPP" build > "$TMP/b.log" 2>&1 || rc=$?; echo "$rc" > "$TMP/b.rc" ) & b=$!
32+
sleep 0.9; kill -9 "$a" 2>/dev/null || true; wait "$a" 2>/dev/null || true
33+
wait "$b" || true
34+
if [ "$(cat "$TMP/b.rc")" != 0 ]; then
35+
echo "FAIL: $schedule round $round: the build after the killed one failed"; cat "$TMP/b.log"; exit 1
36+
fi
37+
( cd "$dir" && "$MCPP" build > "$TMP/c.log" 2>&1 ) || { cat "$TMP/c.log"; exit 1; }
38+
if grep -q "Compiling" "$TMP/c.log"; then
39+
echo "FAIL: $schedule round $round: the following build compiled again"; cat "$TMP/c.log"; exit 1
40+
fi
41+
out=$(cd "$dir" && "$MCPP" run 2>/dev/null | tail -1)
42+
[ "$out" = 25 ] || { echo "FAIL: $schedule round $round: the program printed '$out'"; exit 1; }
43+
done
44+
echo "OK: $schedule: 12 killed builds, each followed by a successful build and a no-op"
45+
done

‎tests/unit/test_schedule_policy.cpp‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import mcpp.manifest;
1515

1616
using mcpp::build::schedule::Strategy;
1717
using mcpp::build::schedule::decide;
18+
using mcpp::build::schedule::decide_shape;
1819
using mcpp::build::schedule::requested_switch;
1920
using mcpp::toolchain::CompilerId;
2021
using mcpp::toolchain::Toolchain;
@@ -256,3 +257,16 @@ TEST(SchedulePolicy, EnvironmentBeatsManifestAndIsValidatedToo) {
256257
EXPECT_EQ(seen, "ON");
257258
}
258259
}
260+
261+
// EVERY STRATEGY CAPS ITS COMPILERS (2026.11.7.1): the capped executor is what
262+
// registers a compiler in its build's generation, so a strategy without a split
263+
// still has a cap -- the job count, else what this machine would choose.
264+
TEST(SchedulePolicy, EveryStrategyCarriesACompilerCap) {
265+
for (auto id : {CompilerId::GCC, CompilerId::Clang, CompilerId::MSVC, CompilerId::Unknown}) {
266+
EXPECT_EQ(decide(with(id), "auto", 8).compilerCap, 8) << static_cast<int>(id);
267+
EXPECT_EQ(decide(with(id), "off", 0, 6).compilerCap, 6) << static_cast<int>(id);
268+
}
269+
// The shape alone is unchanged: only the split strategies carry a cap there.
270+
EXPECT_EQ(decide_shape(with(CompilerId::MSVC), "auto", 8).compilerCap, 0);
271+
}
272+

0 commit comments

Comments
 (0)