From ec0e81a36ae1d04b71d79606a4accfa594cda9df Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Sun, 13 Sep 2026 03:37:32 +0800 Subject: [PATCH 1/6] build.mcpp: an action's lists have no declared size limit The bundled mcpp module held an action's six list fields in fixed arrays (8192 bytes of serialised JSON for inputs and outputs, 16384 for command) and refused a declaration that did not fit. The bound was in bytes, so a consumer's checkout depth decided whether a list of 44 resource files was accepted (HuxerUI#130 measured the margin at 45 bytes), and outputs is the one list an author cannot shorten: an output the program does not name cannot be built, and there is no depfile for outputs. The arrays were fixed because one constraint was read as two. The module must not import std, and its exported interface must name no std type; neither forbids the heap, and was already in the global module fragment. The six arrays become one growable std-free buffer over realloc. The exported surface and the protocol version are unchanged, and the payload of every action that fit before is byte-identical, so the cache key is untouched. The overflow marker keeps its wire form and now means allocation failure; the engine's message says so. e2e 659 declares 200 inputs and 200 outputs and a 19200-byte command and reads the action's edge out of build.ninja; under 2026.9.12.4 the same fixture is refused. --- modules/buildmcpp/src/directives.cppm | 25 +-- src/build/hostprogram.cppm | 124 ++++++++++---- ...an_action_declaration_has_no_size_limit.sh | 161 ++++++++++++++++++ tests/unit/test_build_directives.cpp | 21 +++ 4 files changed, 283 insertions(+), 48 deletions(-) create mode 100755 tests/e2e/659_an_action_declaration_has_no_size_limit.sh diff --git a/modules/buildmcpp/src/directives.cppm b/modules/buildmcpp/src/directives.cppm index 9888893b..a63e74d1 100644 --- a/modules/buildmcpp/src/directives.cppm +++ b/modules/buildmcpp/src/directives.cppm @@ -1088,20 +1088,21 @@ std::string deploy_directive_error(const mcpp::manifest::Manifest& m, const Dire std::string action_error(const Directives& d) { for (auto const& payload : d.at(Slot::Actions)) { - // The typed API sets this when an argv did not fit its fixed buffer. - // Diagnosed separately because "malformed action" would send the - // author looking for a typo in something that was actually correct - // and merely too long. + // The typed API sets this when the build program could not allocate + // memory for one of the action's lists. Diagnosed separately because + // "malformed action" would send the author looking for a typo in a + // declaration that was correct and merely cut short. The lists have + // no declared size limit (they had one, 8192 bytes of serialised + // JSON, until 2026.9.13.1); the OS bounds the COMMAND's argv at run + // time, and that is a limit of the tool's own command line, which a + // response file or a directory argument shortens. if (payload.find("\"overflow\":true") != std::string::npos) { return std::format( - "build.mcpp declared an action whose arguments did not fit.\n" - " The typed `mcpp::action` builder uses fixed buffers " - "(the bundled module has to stay\n" - " buildable before a std module exists, so it cannot use " - "std::string).\n" - " Shorten the command — e.g. pass a response file, or a " - "directory instead of\n" - " enumerating its files.\n" + "build.mcpp declared an action whose lists could not be stored.\n" + " The build program ran out of memory while collecting the " + "action's inputs,\n" + " outputs or command, so the declaration is incomplete " + "and cannot be used.\n" " payload: {}", payload); } if (decode_action(payload)) continue; diff --git a/src/build/hostprogram.cppm b/src/build/hostprogram.cppm index 6ac6c67c..31a6b955 100644 --- a/src/build/hostprogram.cppm +++ b/src/build/hostprogram.cppm @@ -204,21 +204,21 @@ struct action { // field existed. See BuildAction::depfile (modules/manifest/src/types.cppm) // for why `inputs` alone cannot express what this covers. const char* depfile = ""; - action& input(const char* p) { add(inputs_, sizeof inputs_, p); return *this; } - action& output(const char* p) { add(outputs_, sizeof outputs_, p); return *this; } - action& arg(const char* a) { add(command_, sizeof command_, a); return *this; } + action& input(const char* p) { add(inputs_, p); return *this; } + action& output(const char* p) { add(outputs_, p); return *this; } + action& arg(const char* a) { add(command_, a); return *this; } // Declare what a generated MODULE INTERFACE provides/imports. Same // "declare instead of discover" trade [modules].scan_overrides makes, and // what lets a generated .cppm exist as a graph node at all. - action& provides(const char* n) { add(provides_, sizeof provides_, n); return *this; } - action& imports(const char* n) { add(imports_, sizeof imports_, n); return *this; } + action& provides(const char* n) { add(provides_, n); return *this; } + action& imports(const char* n) { add(imports_, n); return *this; } // Object only: which link unit receives the outputs. Omit for "every image // this package produces" — which INCLUDES test binaries, and is what you // want: their names come from tests/*.cpp, so spelling one here breaks // plain `mcpp build`, where that link unit does not exist. An Artifact reads // its target out of ${mcpp.target_file:NAME}; an Object runs before the link // and has no such handle, so it has to say the name. - action& target(const char* n) { add(targets_, sizeof targets_, n); return *this; } + action& target(const char* n) { add(targets_, n); return *this; } void submit() const { std::printf("mcpp:action={\"id\":"); esc(id); std::printf(",\"role\":"); esc(role); @@ -232,25 +232,69 @@ struct action { // has nothing to do with depfiles. The decoder's default (empty // string) is identical either way, so omission costs nothing on read. if (depfile[0]) { std::printf(",\"depfile\":"); esc(depfile); } - // A truncated argv would otherwise be INVALID rather than obviously - // wrong — the engine turns this marker into a diagnostic that names - // the limit, instead of a generic "malformed action". + // Set only when the process could not allocate memory for a list. + // A declaration cut short would otherwise be INVALID rather than + // obviously wrong -- the engine turns this marker into a diagnostic + // that names the cause, instead of a generic "malformed action". if (overflow_) std::printf(",\"overflow\":true"); - std::printf(",\"inputs\":[%s]", inputs_); - std::printf(",\"outputs\":[%s]", outputs_); - std::printf(",\"command\":[%s]", command_); - std::printf(",\"provides\":[%s]", provides_); - std::printf(",\"imports\":[%s]", imports_); - std::printf(",\"targets\":[%s]", targets_); + std::printf(",\"inputs\":[%s]", inputs_.c_str()); + std::printf(",\"outputs\":[%s]", outputs_.c_str()); + std::printf(",\"command\":[%s]", command_.c_str()); + std::printf(",\"provides\":[%s]", provides_.c_str()); + std::printf(",\"imports\":[%s]", imports_.c_str()); + std::printf(",\"targets\":[%s]", targets_.c_str()); std::printf("}\n"); } private: - // Fixed buffers because this module must stay buildable BEFORE a std BMI - // exists (it is what a build.mcpp imports, and it may be compiled first) — - // so no std::string. Sizes chosen for real generator invocations: a protoc - // command line with many -I paths runs long. - char inputs_[8192]{}, outputs_[8192]{}, command_[16384]{}, - provides_[2048]{}, imports_[2048]{}, targets_[1024]{}; + // One list field, held already serialised (`"a","b"`) so submit() prints + // it as it is. Owning and std-free, and both words are constraints this + // module carries: it may be compiled before a std BMI exists, so it must + // not `import std;`, and its exported interface must name no std type, so + // `std::string` may not appear in a signature. Neither forbids the heap: + // storage is `realloc` from the `` already in the global module + // fragment, and no exported signature mentions this type. + // + // An earlier revision held six fixed arrays (8192 bytes for `inputs` and + // `outputs`, chosen for a protoc command line) and a declaration that did + // not fit was refused. The bound was in bytes of serialised JSON, so a + // consumer's checkout depth decided whether a resource list of 44 files + // fit (HuxerUI#130 measured the margin at 45 bytes), and `outputs` is the + // one list an author cannot shorten: an output the program does not name + // cannot be built, and there is no depfile for outputs. See + // .agents/docs/2026-09-13-four-upstream-asks-from-a-ui-framework.md. + struct list { + char* p = nullptr; + unsigned long len = 0, cap = 0; + list() = default; + list(const list& o) { take(o); } + list& operator=(const list& o) { if (this != &o) { len = 0; take(o); } return *this; } + ~list() { std::free(p); } + const char* c_str() const { return p ? p : ""; } + // Grows by doubling. False only when the allocator refuses. + bool reserve(unsigned long need) { + if (need <= cap) return true; + unsigned long c = cap ? cap : 256; + while (c < need) c *= 2; + void* q = std::realloc(p, c); + if (!q) return false; + p = static_cast(q); + cap = c; + return true; + } + bool put(char c) { + if (!reserve(len + 2)) return false; + p[len++] = c; + p[len] = 0; + return true; + } + void take(const list& o) { + if (!o.len) { if (p) p[0] = 0; return; } + if (!reserve(o.len + 1)) return; + for (unsigned long i = 0; i <= o.len; ++i) p[i] = o.p[i]; + len = o.len; + } + }; + list inputs_, outputs_, command_, provides_, imports_, targets_; mutable bool overflow_ = false; static void esc(const char* s) { std::putchar('"'); @@ -265,22 +309,30 @@ private: } std::putchar('"'); } - // Capacity is a PARAMETER. The previous revision hardcoded 4096 while the - // smallest buffer here was 1024 — a bound living somewhere other than next - // to the array it bounds is exactly the shape that overflows. - bool add(char* buf, unsigned long cap, const char* s) { - unsigned long o = 0; while (buf[o]) ++o; - if (o + 4 >= cap) { overflow_ = true; return false; } - if (o) buf[o++] = ','; - buf[o++] = '"'; - for (const char* p = s; *p; ++p) { - if (o + 3 >= cap) { buf[o] = 0; overflow_ = true; return false; } - if (*p == '"' || *p == '\\') buf[o++] = '\\'; - buf[o++] = *p; + // Appends one JSON string literal, with the escaping `esc` applies, so a + // list entry and a scalar field are encoded by one rule. A payload that + // decoded under the fixed-array revision is encoded to the same bytes + // here: that revision escaped `"` and `\\` and passed control characters + // through, and a control character passed through was not JSON, so no + // payload the engine accepted contained one. + bool add(list& l, const char* s) { + bool ok = true; + if (l.len) ok = ok && l.put(','); + ok = ok && l.put('"'); + for (const char* p = s; ok && *p; ++p) { + unsigned char c = (unsigned char)*p; + if (c == '"' || c == '\\') { ok = l.put('\\') && l.put((char)c); continue; } + if (c < 0x20) { + static const char hex[] = "0123456789abcdef"; + ok = l.put('\\') && l.put('u') && l.put('0') && l.put('0') + && l.put(hex[c >> 4]) && l.put(hex[c & 0xf]); + continue; + } + ok = l.put((char)c); } - buf[o++] = '"'; - buf[o] = 0; - return true; + ok = ok && l.put('"'); + if (!ok) overflow_ = true; + return ok; } }; inline void rerun_if_changed(const char* path) { std::printf("mcpp:rerun-if-changed=%s\n", path); } diff --git a/tests/e2e/659_an_action_declaration_has_no_size_limit.sh b/tests/e2e/659_an_action_declaration_has_no_size_limit.sh new file mode 100755 index 00000000..22091558 --- /dev/null +++ b/tests/e2e/659_an_action_declaration_has_no_size_limit.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash +# 659_an_action_declaration_has_no_size_limit.sh -- `mcpp::action`'s list +# fields have no declared size limit (2026.9.13.1). +# +# Until this release the bundled `mcpp` module collected an action's inputs, +# outputs and command into fixed arrays (8192 bytes of serialised JSON for +# `inputs` and `outputs`, 16384 for `command`) and refused a declaration that +# did not fit with "arguments did not fit". The bound was in bytes, so a +# consumer's checkout depth decided whether a list of 44 resource files fit +# (HuxerUI#130 measured the margin at 45 bytes), and `outputs` is the one list +# an author cannot shorten: an output the program does not name cannot be +# built, and there is no depfile for outputs. +# +# This fixture declares one action whose serialised `inputs` and `outputs` each +# exceed the old bound by construction, and asserts that the whole declaration +# reaches the graph: `build.ninja` carries an edge with exactly N outputs and N +# inputs, N being this file's constant and not a number read back from the +# engine. The reverse leg -- the same fixture on 2026.9.12.4 is refused with +# the overflow diagnostic -- was run once before merge and is recorded in the +# pull request; CI cannot run two engines on one fixture. +# +# The command is the engine itself, which is present on every shard, exits 0 +# and writes nothing, so the fixture runs everywhere the suite runs (no +# `# requires:` line, as 313 does). The outputs are therefore not produced; +# ninja does not fail on an unproduced output (313 states the measurement), and +# producing files is the command's business, covered by 188. What is under +# test here is the declaration channel. +# +# A second action carries a command past 16384 bytes on the hosts whose OS +# allows it. Windows is skipped for that leg only: ninja runs every command +# through `cmd /c` there, which caps a command line at 8191 characters, and +# that is the operating system's limit on the tool's own argv, not the +# engine's on the declaration (the design record's section 2.4). +set -e + +source "$(dirname "$0")/_host_path.sh" + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" + +readonly N=200 + +mkdir -p app/src app/in +cd app + +cat > mcpp.toml <<'EOF' +[package] +name = "app" +version = "0.1.0" + +[targets.app] +kind = "bin" +main = "src/main.cpp" +EOF + +cat > src/main.cpp <<'EOF' +#include +int main() { std::puts("LARGE_DECLARATION_OK"); } +EOF + +# N input files, so that ninja finds every declared input on disk. Each path +# is padded to a fixed width with a long directory name, so the serialised +# list crosses the old bound regardless of where this fixture runs: +# N * (width + 3) > 8192 whenever width >= 40. +PAD="a-directory-name-long-enough-to-make-the-list-cross-the-old-bound" +mkdir -p "in/$PAD" +for i in $(seq 1 $N); do : > "in/$PAD/input-$i.txt"; done + +ROOT_HOST=$(host_path "$PWD") +MCPP_HOST=$(host_path "$(cd "$(dirname "$MCPP")" && pwd)/$(basename "$MCPP")") + +# The long-command leg: a program that accepts any argv and exits 0. Not on +# Windows (see the header). +TRUE_HOST="" +case "$(uname -s)" in + MINGW* | MSYS* | CYGWIN*) ;; + *) TRUE_HOST=$(host_path "$(type -P true)") ;; +esac + +cat > build.mcpp < b1.log 2>&1 || { cat b1.log; echo "FAIL: a build with a wide action was refused"; exit 1; } +out="$("$MCPP" run 2>&1 | tail -1)" +[[ "$out" == *LARGE_DECLARATION_OK* ]] || { echo "FAIL: the program did not run: '$out'"; exit 1; } + +NINJA=$(find target -name build.ninja -print -quit) +[ -n "$NINJA" ] || { echo "FAIL: no build.ninja"; exit 1; } + +# THE criterion: the edge in the graph names every declared output and every +# declared input. Counted against N, not against anything the engine reports. +# Only the action's own edge is read (`build : mcpp_action_ +# `): the engine also lists a source action's outputs a second time, +# as the inputs of the package's ordering phony, and counting the whole file +# would report 2N. +count_in_edge() { + # $1 = the substring that identifies the file kind + grep ': mcpp_action_' "$NINJA" | tr -s ' ' '\n' | grep -c "$1" || true +} +outs=$(count_in_edge "output-[0-9]*\.txt") +ins=$(count_in_edge "input-[0-9]*\.txt") +[ "$outs" = "$N" ] || { echo "FAIL: build.ninja names $outs of $N declared outputs"; exit 1; } +[ "$ins" = "$N" ] || { echo "FAIL: build.ninja names $ins of $N declared inputs"; exit 1; } +echo "ok: the edge carries $N outputs and $N inputs" + +if [ -n "$TRUE_HOST" ]; then + stamp=$(find target -name 'long-command.stamp' | wc -l | tr -d '[:space:]') + [ "$stamp" = "1" ] || { cat b1.log; echo "FAIL: the long-command check did not run (stamps: $stamp)"; exit 1; } + echo "ok: a command of 19200 bytes ran" +fi + +# The overflow marker must be absent: the refusal path of the old bound is the +# only thing that ever wrote it, and the message it now carries is about +# allocation failure, which this build did not have. +if grep -q '"overflow":true' b1.log; then cat b1.log; echo "FAIL: the overflow marker appeared"; exit 1; fi + +# Replay: an unrelated source is added, so the project fast path is off and +# the build program's cache record is replayed rather than re-run. The edge +# must come back complete from the record, not only from a live run. +cat > src/extra.cpp <<'EOF' +int extra() { return 1; } +EOF +"$MCPP" build > b2.log 2>&1 || { cat b2.log; echo "FAIL: rebuild after an unrelated source failed"; exit 1; } +NINJA=$(find target -name build.ninja -print -quit) +outs=$(count_in_edge "output-[0-9]*\.txt") +[ "$outs" = "$N" ] || { echo "FAIL: after a replay build.ninja names $outs of $N declared outputs"; exit 1; } +echo "ok: the replayed record carries all $N outputs" + +echo "OK" diff --git a/tests/unit/test_build_directives.cpp b/tests/unit/test_build_directives.cpp index dada6cee..ac837f84 100644 --- a/tests/unit/test_build_directives.cpp +++ b/tests/unit/test_build_directives.cpp @@ -721,6 +721,27 @@ TEST(BuildDirectives, DecodeActionDefaultsDepfileToEmptyWhenAbsent) { EXPECT_EQ(a->depfile, ""); } +// The overflow marker. Until 2026.9.13.1 `mcpp::action` held its lists in +// fixed arrays and set the marker when a declaration did not fit; the lists +// now grow, and the marker means the build program could not allocate. The +// refusal stays -- an incomplete declaration must never be used -- and the +// message has to say what is now true: nothing about a buffer size, nothing +// recommending a response file for a list that no longer needs one. +TEST(BuildDirectives, OverflowMarkerIsRefusedAsAllocationFailure) { + auto d = parse( + "mcpp:action={\"id\":\"wide\",\"role\":\"source\"," + "\"description\":\"\",\"blocking\":false,\"overflow\":true," + "\"inputs\":[],\"outputs\":[\"out/a.txt\"]," + "\"command\":[\"gen\"],\"provides\":[],\"imports\":[],\"targets\":[]}\n"); + auto err = dirs::action_error(d); + ASSERT_FALSE(err.empty()); + EXPECT_NE(err.find("could not be stored"), std::string::npos); + EXPECT_NE(err.find("out of memory"), std::string::npos); + EXPECT_EQ(err.find("fixed buffer"), std::string::npos); + EXPECT_EQ(err.find("response file"), std::string::npos); + EXPECT_EQ(err.find("did not fit"), std::string::npos); +} + // ── #618: a named executable's subsystem and entry ────────────────────────── // // `windows-subsystem` and `windows-entry` name a target of the package being From 69452a333ca3d4d97d5134fb1b03810023739b0a Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Sun, 13 Sep 2026 03:37:32 +0800 Subject: [PATCH 2/6] build.mcpp: ${mcpp.self} names the engine, and mcpp stage is the portable copy An action's command is an argv with no shell, so a build program had no portable way to copy a file: cp is absent on Windows, cmd /c copy is a shell and an 8191-character limit, and a copier carried by a package is a host-tool sub-build for one copy. The engine is the one program present wherever a build runs, and mcpp stage --verify content --output is the copy every stage_file edge already performs. ${mcpp.self} joins the argv substitution family and is replaced by the engine's absolute path, as the check wrapper already bakes it in. mcpp stage's argument shape is a contract from here on; its help text now states the real default (content). e2e 660 copies the linked program through the token on every shard; under 2026.9.12.4 the token stays literal and the edge fails. --- src/build/prepare.cppm | 12 +++ src/cli.cppm | 11 ++- ...tion_names_the_engine_through_mcpp_self.sh | 93 +++++++++++++++++++ 3 files changed, 113 insertions(+), 3 deletions(-) create mode 100755 tests/e2e/660_an_action_names_the_engine_through_mcpp_self.sh diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 9b20fa89..9e7dca67 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -11138,6 +11138,18 @@ prepare_build(bool print_fingerprint, rep("${mcpp.out_dir}", ctx.plan.outputDir.string()); rep("${mcpp.bin_dir}", (ctx.plan.outputDir / "bin").string()); rep("${mcpp.compile_db}", ctx.plan.compileDbPath.string()); + // The engine's own executable, absolute (2026.9.13.1+). An action + // whose command is an argv with no shell has no portable way to + // copy, touch or compare a file, and the engine is the one + // program present wherever a build runs -- the reason a `check` + // is wrapped with `mcpp __action-stamp` (ninja_backend.cppm). This + // token lets a build program say the same thing: `${mcpp.self} + // stage --verify content --output ` is the copy every + // `stage_file` edge already performs. The same caveat as the + // wrapper's: a version change regenerates build.ninja, and a + // binary moved under an unchanged version leaves a stale path, + // exactly as it would for the compiler. + rep("${mcpp.self}", mcpp::platform::fs::self_exe_path().string()); // ABSOLUTE, unlike `${mcpp.target_file:}` and for the same reason // stated the other way round: the staged tree lives outside the // build directory and no ninja edge produces it, so there is no diff --git a/src/cli.cppm b/src/cli.cppm index ba1149f9..e2d8e010 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -854,12 +854,17 @@ int run(int argc, char** argv) { .option(cl::Option("expect-none") .help("(verification) planner assumed no provides/imports")) .action(wrap_rc(cmd_dyndep))) + // Named by generated build.ninja edges and, since 2026.9.13.1, by a + // build program's own actions through `${mcpp.self}` (docs/30), so + // the argument shape below is a contract: `stage --verify content + // --output ` copies one file, creates the destination's + // parent, and writes only when the bytes differ. .subcommand(cl::App("stage") - .description("(internal: invoked by ninja) Stage a cached artifact into the build dir") + .description("Copy one file into place: create the destination's parent, write only when the content differs (invoked by build.ninja and by ${mcpp.self} actions)") .option(cl::Option("output").short_name('o').takes_value().value_name("PATH") - .help("Destination path inside the build directory")) + .help("Destination path; its parent directory is created")) .option(cl::Option("verify").takes_value().value_name("MODE") - .help("Already-staged check: size (default) | content")) + .help("How an existing destination is judged up to date: content (default) | size")) .action(wrap_rc(cmd_stage))) .subcommand(cl::App("coff-def") .description("(internal: invoked by ninja) Write a .def of every exportable symbol in the given COFF objects") diff --git a/tests/e2e/660_an_action_names_the_engine_through_mcpp_self.sh b/tests/e2e/660_an_action_names_the_engine_through_mcpp_self.sh new file mode 100755 index 00000000..efeaf3f8 --- /dev/null +++ b/tests/e2e/660_an_action_names_the_engine_through_mcpp_self.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# 660_an_action_names_the_engine_through_mcpp_self.sh -- `${mcpp.self}` in an +# action's argv is the engine's own executable, and `mcpp stage` through it is +# a portable copy (2026.9.13.1). +# +# An action's command is an argv with no shell assumed, so a build program had +# no portable way to copy a file: `cp` is absent on Windows, `cmd /c copy` is a +# shell and an 8191-character limit, and a copier carried by a package is a +# host-tool sub-build for one copy. The engine is the one program present +# wherever a build runs, and `mcpp stage --verify content --output ` +# is the copy every `stage_file` edge in build.ninja already performs: it +# creates the destination's parent and writes only when the bytes differ. +# `${mcpp.self}` is how an action names it, in the same substitution family as +# `${mcpp.out_dir}` and `${mcpp.target_file:}`. +# +# No `# requires:` line: the point is that this works on every shard, and the +# Windows one is the shard it exists for. The reverse leg -- under 2026.9.12.4 +# the token stays literal in build.ninja and the edge fails -- was run once +# before merge and is recorded in the pull request. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" + +mkdir -p app/src +cd app + +cat > mcpp.toml <<'EOF' +[package] +name = "app" +version = "0.1.0" + +[targets.app] +kind = "bin" +main = "src/main.cpp" +EOF + +cat > src/main.cpp <<'EOF' +#include +int main() { std::puts("SELF_COPY_OK"); } +EOF + +cat > build.mcpp <<'EOF' +import std; +import mcpp; +int main() { + mcpp::action a; + a.id = "copy"; + a.role = "artifact"; + a.description = "the linked program, copied by the engine"; + a.arg("${mcpp.self}").arg("stage").arg("--verify").arg("content") + .arg("--output").arg("${mcpp.out_dir}/copied/deeper/app-copy") + .arg("${mcpp.target_file:app}") + .input("${mcpp.target_file:app}") + .output("${mcpp.out_dir}/copied/deeper/app-copy") + .submit(); +} +EOF + +"$MCPP" build > b1.log 2>&1 || { cat b1.log; echo "FAIL: build failed"; exit 1; } +out="$("$MCPP" run 2>&1 | tail -1)" +[[ "$out" == *SELF_COPY_OK* ]] || { echo "FAIL: the program did not run: '$out'"; exit 1; } + +NINJA=$(find target -name build.ninja -print -quit) +[ -n "$NINJA" ] || { echo "FAIL: no build.ninja"; exit 1; } + +# The token is substituted, never emitted literally. +if grep -q 'mcpp\.self' "$NINJA"; then + grep -n 'mcpp\.self' "$NINJA" | head -3 + echo "FAIL: \${mcpp.self} reached build.ninja unsubstituted"; exit 1 +fi + +# The copy exists, in a directory the action never created itself, and is the +# program byte for byte: the only way it got there is the engine's `stage`. +COPY=$(find target -path '*/copied/deeper/app-copy' -print -quit) +[ -n "$COPY" ] || { cat b1.log; echo "FAIL: the copy action produced nothing"; exit 1; } +BIN=$(find target -path '*/bin/app' -print -quit) +[ -n "$BIN" ] || BIN=$(find target -path '*/bin/app.exe' -print -quit) +[ -n "$BIN" ] || { echo "FAIL: no linked program under target/"; exit 1; } +cmp -s "$BIN" "$COPY" || { echo "FAIL: the copy differs from the program"; exit 1; } +echo "ok: the engine copied the program through \${mcpp.self}" + +# A no-op rebuild leaves the copy alone: the edge is satisfied by its output, +# and `stage --verify content` would write nothing even if it ran. +before=$(stat -c %Y "$COPY" 2>/dev/null || stat -f %m "$COPY") +sleep 1 +"$MCPP" build > b2.log 2>&1 || { cat b2.log; echo "FAIL: no-op rebuild failed"; exit 1; } +after=$(stat -c %Y "$COPY" 2>/dev/null || stat -f %m "$COPY") +[ "$before" = "$after" ] || { echo "FAIL: the copy was rewritten on a no-op rebuild ($before -> $after)"; exit 1; } +echo "ok: a no-op rebuild copied nothing" + +echo "OK" From 3471fa7527ff74106f0fc7c771594dac3017b551 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Sun, 13 Sep 2026 03:37:32 +0800 Subject: [PATCH 3/6] docs: the action lists' limit, the ${mcpp.self} row, and the design record for the four upstream asks --- ...-four-upstream-asks-from-a-ui-framework.md | 586 ++++++++++++++++++ .agents/docs/README.md | 7 +- CHANGELOG.md | 23 + docs/30-build-mcpp.md | 13 + docs/zh/30-build-mcpp.md | 10 + 5 files changed, 638 insertions(+), 1 deletion(-) create mode 100644 .agents/docs/2026-09-13-four-upstream-asks-from-a-ui-framework.md diff --git a/.agents/docs/2026-09-13-four-upstream-asks-from-a-ui-framework.md b/.agents/docs/2026-09-13-four-upstream-asks-from-a-ui-framework.md new file mode 100644 index 00000000..4b9c5757 --- /dev/null +++ b/.agents/docs/2026-09-13-four-upstream-asks-from-a-ui-framework.md @@ -0,0 +1,586 @@ +--- +subject: build-program +status: active +--- + +# Four upstream asks from a UI framework: what each one is under mcpp's design, and the combined plan + +**Status:** proposed and reviewed (2026-09-13). Nothing here is implemented. +The first ask was analysed in an earlier draft of this record (the action +buffer, §2); this revision reads all four against the rules the 2026-09-11 +and 2026-09-12 records state, and replaces that draft. The review accepted +the four conclusions and added four things, recorded in §8 and folded into +the sections they touch. Engine facts were read at `baa89d2b` (mcpp +2026.9.12.4), mcpp-plugins at `db16e27` (0.8.0). `mcpp::deploy` and +`mcpp::min_platform_version()` are landed engine facts (2026.9.12.3, +protocol 11), not pending ones; an earlier draft read them from a branch. + +## 0. The asks, as HuxerUI states them + +| # | repository | ask | blocks | HuxerUI's workaround | +|---|---|---|---|---| +| 1 | mcpp | `mcpp::action`'s six fixed `char[]` become heap buffers | stage 0, resources via `mcpp::deploy` | one bridging action per payload file, each declaration under the bound | +| 2 | mcpp-plugins | `dist-apk`: a project manifest template with tokens; `java_sources` becomes an array, one `javac`, one `d8` | the whole Android stage | overwrite the member's manifest after `plan_for()`; merge Java into one directory at prepare | +| 3 | mcpp-plugins | `dist-web`: the per-file `cp` becomes a portable copy | nothing (Windows hosts pack Web elsewhere) | pack on Linux or macOS | +| ½ | xim-pkgindex | `cubism-sdk-native`, `cubism-sdk-web` at tier 2 | nothing (a downstream library, not HuxerUI) | Lib-Live2D's fetch script | + +Web and iOS need nothing upstream; the wasm row is measured configuring. +Android is blocked by 2 alone. The framework's stage 0 is blocked by 1 +alone. + +Each ask is read against the same questions: which rule of the design it +touches, whether the precedent already exists in the collection, what the +member or engine alone knows and therefore must own, and what the criterion +is in both directions (2026-09-12 record, rules 7 and 8). The readings +change two of the four: ask 3's answer is an engine token plus a member +edit, not a member edit; ask 2's manifest check is a construction rule, not +an XML parse. + +## 1. The rules the four are read against + +From the 2026-09-12 record (§1), the ones that bind here: + +- **Rule 1, per project / per target / per shape.** A per-shape knob is a + member option; a per-project value is a manifest key; a per-target value is + the engine's. +- **Rule 3, declare unconditionally, submit conditionally.** +- **Rule 4, a name is cache-safe, a path is not.** +- **Rule 7, each item has its own criterion.** A requirement folded into a + neighbour's fix disappears when the neighbour ships. +- **Rule 8, the negative direction is part of the criterion** wherever a + check can pass while measuring nothing. +- **Rule 9, the package, not the host path.** + +From the 2026-09-11 record (§6) and the mcpp-plugins README: a dist member +exposes a plan/submit pair so a project edit is an edit and not a +reimplementation; it names its inputs rather than harvesting a directory; a +member drives what the ecosystem resolved and reads nothing from the host; +a member that runs a program says where the program comes from. + +From the engine: the graph fixes the output set at prepare (docs/30, "You +must name the output files"); an action's command is an argv with no shell +assumed; "the engine is the portable wrapper. It is already on disk on every +platform mcpp runs on" (`cli.cppm:920`, the reason `__action-stamp` exists). + +## 2. Ask 1: the action declaration is bounded by a fixed buffer + +### 2.1 The bound + +The bundled `mcpp` module every build.mcpp imports is a string literal in +`src/build/hostprogram.cppm`, compiled per build.mcpp with `` and +`` in its global module fragment and no `import std;`. Its +`mcpp::action` collects six list fields into six fixed arrays +(`hostprogram.cppm:252`): `inputs_` 8192, `outputs_` 8192, `command_` 16384, +`provides_` 2048, `imports_` 2048, `targets_` 1024. Capacity is bytes of +serialised JSON, not entries. When a literal does not fit, `add()` drops it +and `submit()` writes `"overflow":true`; the engine refuses with "build.mcpp +declared an action whose arguments did not fit" and recommends a response +file or a directory input (`directives.cppm:1095`). + +HuxerUI#130 measured the same 44 files under three unpack prefixes: + +| prefix | `inputs_` bytes | result | +|---|---|---| +| 149 chars | 8131 + ~106 | refused | +| 109 chars | 6371 | builds | +| in-tree, relative | 1531 | builds | + +The margin is about 45 bytes, and which row a consumer gets is decided by +where their checkout sits. No other limit in the action pipeline has that +property. + +### 2.2 Why fixed, and which half of the reason holds + +The comment at line 248 says the module "must stay buildable BEFORE a std +BMI exists", so no `std::string`. Two constraints are folded into that: + +- The module must not `import std;`. True: it is compiled with the build + program's base flags and no module references, and the engine stages a std + BMI only when build.mcpp asks for one. This does not forbid `#include` in + the global module fragment, which is how `` is already there. +- The module's exported interface must name no std type. Not written in the + comment, and the stronger reason: a build.mcpp with `import std;` and + `import mcpp;` would see `std::string` by two routes, and the repository + has measured what std types in a widely imported interface do to + downstream BMIs (`2026-08-11-source-kind-table-and-build-program-timeout.md` + §7.2). Every public member of `action` is `const char*` or `bool`; that + stays. + +Neither constraint concerns the heap. `std::realloc` and `std::free` come +from the `` already included, exist on GCC, Clang and MSVC, and +appear in no exported signature. The fixed arrays were the simplest std-free +storage, not the only one, and the sizes were a guess about protoc command +lines. + +### 2.3 Why the outputs side has no way around it + +Inputs were worked around correctly: the action emits a Make-style +dependency file (`action::depfile`, #587, 2026-09-08) and `inputs` lists the +tool. `mcpp::deploy(from, to)` (#622 A4, landed in 2026.9.12.3, protocol 11) moves the same list to +the other side. Its `from` is a copy edge's input in the ninja graph, so it +must exist at prepare or be a declared action output; an `hrc` product is +the second kind, so every deployed file must be in `outputs_`. Three rules +the engine states and is right about close every exit: + +1. Names may not arrive later (`prepare_actions` fixes the set before + anything runs). There is no depfile for outputs and cannot be one. +2. A directory is not an output (the #622 record, §2.4: dirty on the wrong + events, members unknown to the graph). +3. A response file is the tool's flag. `hrc --root ` already takes a + directory; that shortens the command, which was never the field that + overflowed. + +The framework's 44 files with the `out_dir` prefix serialise to about 5 KB. +An application's resources have no upper count. + +### 2.4 What bounds an action once the buffer is gone + +| stage | bound | +|---|---| +| `submit()` → stdout | none (`std::printf` into the capture pipe) | +| engine capture (`build_program.cppm:757`) | none (`std::getline`) | +| cache record | none; stored verbatim, replayed byte-identical | +| `decode_action` | none | +| `build.ninja` edge lists (`ninja_backend.cppm:2414`) | none; never an argv | +| the action's **command** at run time | OS argv: Linux 128 KiB per argument, Windows 32767 (`CreateProcess`), 8191 (`cmd.exe`) | + +The last row is real, is the operating system's, bounds the tool's own argv, +and is answered only by the tool taking a response file or a directory. The +fixed array is the only artificial bound on `inputs` and `outputs`. + +### 2.5 Three answers, and the recommendation + +**A. A growable buffer inside the module.** A small owning type in the +module's private section over `realloc`, replacing the six arrays. No +exported signature changes, no new directive, no protocol bump; the payload +of every action that fit before is byte-for-byte what it was, so the cache +key `apply()` stores is untouched. `add()` loses its `while (buf[o]) ++o;` +scan (linear per call, quadratic per declaration) and its capacity +parameter. `overflow_` keeps its wire form and now means allocation failure; +the engine's message says so and drops the sentence about fixed buffers. +`action` gains a destructor and deep copies; every use read in the ecosystem +(`mcpp-plugins` rules and dist members, `mcpp-accel` examples, HuxerUI) +declares a local and calls `submit()`, and `action` was never an aggregate. +About thirty lines. Recommended. + +**B. A response file for the declaration.** The module writes the lists to +a file under `out_dir`; the engine reads it at parse and on every replay; +the cache record must carry its contents or hash. Three moving parts and one +new way for a cache hit to be wrong. Rejected. + +**C. Streaming directives**, one line per entry. Changes the wire shape, the +record, the decoder and the same-bytes invariant, and needs a protocol bump. +Rejected. + +The response file the current message recommends is the right answer for +the one row of §2.4 the engine cannot remove and the wrong one for the row +it can. + +### 2.6 The workaround, read + +One bridging action per payload file, its input the index and its output +that file, is legal under the graph's rules and keeps every declaration +under the bound. It is N edges declared to satisfy a buffer, each an edge +whose command does nothing the producing action did not already do; the +graph carries it, ninja schedules it, and the log names it. It is the +correct interim shape if the release is not waited for, and it is the shape +the engine should make unnecessary. + +## 3. Ask 2: `dist-apk` takes a manifest template and several Java roots + +### 3.1 What the member does today + +`dist-apk` 0.8.0 generates `AndroidManifest.xml` at plan time from four +values (`manifest_xml`, `apk.cppm:314`): the application id, the label, the +SDK levels, and either `android.app.NativeActivity` with `lib_name` (level +0) or `options::activity` (level 1). It also writes `assets/mcpp-run.json` +from the same id and activity, which `adb-run` reads to start the +application without `aapt2` on the running machine. `java_sources` is one +directory; `javac` compiles every `.java` under it, `d8` dexes the result. + +What HuxerUI's host needs in the manifest, read from the framework's own +Gradle-side files (`platform/android/huxerui/src/main/AndroidManifest.xml` +and the `local_notification` example): `configChanges` on the activity, +`uses-permission` entries, a `receiver` and `meta-data` for notifications, +`android:icon`, an `activity-alias` for a URL scheme. Gradle merges the +library's manifest into the application's; mcpp has no merger and should +not grow one, for the reason `dist-web` gives for its page: which shape the +host needs is the program's contract, not the member's guess. The Java host +is in two places: 25 files in the framework package and the application's +own `Activity` in the project. + +### 3.2 The manifest: a template with tokens, and the precedent on both sides + +The collection already has both shapes. `dist-web` renders a project +`template_file` with `{{name}}` and `{{title}}`; `dist-wix` takes a whole +`.wxs` and passes the one value it cannot bake in (`$(Executable)`) through +`-d`. The question is which values are the member's, because those are the +ones a project file cannot spell as literal text: + +| token | who knows it | why the project cannot write it | +|---|---|---| +| `{{application_id}}` | member, from `[package]` or `options::application_id` | the run sidecar is written from the same value | +| `{{activity}}` | member, from `options::activity` or the level-0 constant | same | +| `{{lib_name}}` | member, from the `app` target's name | the link output's stem is the engine's | +| `{{min_sdk}}` | member, from `mcpp::min_platform_version()` (A11, landed 2026.9.12.3) | a manifest key the engine resolves, not the member | +| `{{target_sdk}}` | member, from the platform payload's directory name | read back from `xpkg_dir`, so it cannot disagree with `-I android.jar` | +| `{{label}}` | member, from `options::label` | convenience; a project may write the literal | + +The rule that follows, and where it differs from the ask: **the tokens that +carry a value the member also writes elsewhere are required, not merely +substituted.** A template without `{{application_id}}`, `{{activity}}`, and +at level 0 `{{lib_name}}`, is refused at plan time; the message names the +missing token and the reader downstream of it (`assets/mcpp-run.json`, which +`adb-run` starts the application from), so the author learns what the value +is for and not only that it is missing. The default template's comments mark +the three required tokens. + +What the rule guarantees is stated exactly: **the token appears**. It does +not guarantee that the token lands in the `package=` attribute; an author +who writes `{{application_id}}` into a comment and a different literal into +`package=` has defeated it, and the member cannot see that without reading +the XML. That is a deliberate act and not the member's to prevent. The +ordinary case, an author who forgets, is caught. The ask's alternative, +rendering and then checking +`package=`, the launcher activity and `lib_name` in the result, needs a +second reader of the XML inside the member, and a second parser of one file +is the shape that reports the whole file wrongly +(`second-parser-reports-the-whole-file`); a substring check is the shape +that goes quiet when the spelling changes. Requiring the token is the same +guarantee with no parser. A project that wants a fixed package name sets +`options::application_id` and keeps the token. + +An unknown `{{...}}` in the template is refused at plan time, naming it. +`dist-web` does not do this today and leaves an unknown token literal; for a +manifest that literal would reach `aapt2` and fail there with a worse +message, and the refusal belongs where the name is known. + +Everything else in the file is the project's, verbatim: permissions, +receivers, meta-data, icon, aliases, `configChanges`. The member adds +nothing to it. The default template is the file `manifest_xml` generates +today, expressed with the same tokens, so level 0 with no template is +byte-identical to 0.8.0's manifest. + +The rendered manifest is written at plan time to the side file the link step +already takes as input (`/dist-apk/AndroidManifest.xml`), through +`write_if_different`, so a template edit reaches the graph. The template +itself is declared with `rerun_if_changed`, which is the part the ask's +workaround (overwrite after `plan_for()`) cannot do: it depends on the +member's internal path and leaves the sidecar written from the member's +values while the manifest says something else. + +### 3.3 `java_sources` as an array + +One `javac` over every root and one `d8` over its output is the shape; the +member compiles what it is given (#622 §3.2) and a second root is more of +the same input, not a second step. `options::java_sources` becomes +`std::vector`; a single string stays accepted in a +`build.mcpp` because the member is C++ and a one-element initialiser list +is the same spelling. + +The re-run question is the one that needs stating. Today the member +declares `rerun_if_changed_glob("/**/*.java")` so that a file +appearing re-runs the program. `glob_fingerprint` walks the **package +root** (`directives.cppm:805`) and matches paths relative to it; a root +under a dependency's unpack directory is outside that walk, matches +nothing, and the fingerprint is the same as "no files", which is a +criterion whose "no" reads as silence. So: + +- a project root (under the manifest directory) is declared with the glob, + as today; +- a dependency root is not: its file set changes only with the dependency's + version, which is already in the build's fingerprint, and each file's + content is already an input of the `javac` action. Declaring the glob for + it would be a line that measures nothing. + +The member decides which of the two a root is by whether it lies under +`mcpp::manifest_dir()`, and says nothing for the other case rather than +warning about a normal one. + +One corner is the consumer's and HuxerUI meets it daily: a **path +dependency**. Its three examples declare `huxerui = { path = "../../.." }`, +so during development a `.java` added to the framework changes the file set +under a root the glob does not walk, with no version change to carry it into +the fingerprint. The build program does not re-run, and the new file is not +compiled until something else triggers a re-run. The member's rule is still +right; the answer is the dependency's, and it is the engine's own "declare +instead of discover": the rule package carries a list of its Java sources +(`android/java-sources.txt`, maintained by `huxerui-build-check`, so drift +is red in CI) and declares `rerun_if_changed` on that one file. A file +appearing is then an edit to the list, which is a content change the +fingerprint sees. + +### 3.4 What the ask does not list, noted and not folded in + +`options::resources` is one directory, and the compile step declares that +directory as its input (`apk.cppm:788`), which is the "dirty on the wrong +events" shape §2.3 refuses for outputs. The framework's `res/` and the +application's are two roots for the same reason the Java is. Rule 7: this +is its own item with its own criterion and is not part of ask 2. Recorded +so it is not lost. + +### 3.5 Criteria + +- A template that omits `{{application_id}}` is refused at plan time and the + message names the token; the same template with it renders, and + `aapt2 dump badging` on the linked `base.apk` reports the id and the + launcher activity that `assets/mcpp-run.json` carries, compared as two + values from two files. +- A template with a `uses-permission` and a `receiver` produces an APK whose + `aapt2 dump xmltree` lists both; the default template on the same project + produces neither, and the level-0 manifest with no template is + byte-identical to 0.8.0's. +- Two Java roots, one in the project and one under a dependency, produce one + `classes.dex` containing classes from both (`dexdump` or `d8`'s own + listing); a `.java` added to the project root re-runs the build program on + the next build, and one added to the dependency root does not, which is + the intended reading, not a gap. + +## 4. Ask 3: `dist-web` copies with something that exists on Windows + +### 4.1 What the member says and what the engine already has + +`dist-web` declares one `cp SRC DST` action per staged file, argv only, and +its header says why it is POSIX-only: neither precedent (`appimagetool`'s +own argument list; `ditto`, macOS only) is a portable multi-file copier, and +"lifting it needs either a `copy`-argv branch on the host OS or a small +copier this member carries itself" (`web.cppm:48-59`). + +Both of those are the wrong shape. `cmd /c copy` is a shell, is the 8191 +limit, and is the switch-quoting the repository has already been bitten by +twice. A copier carried by the member is a host tool sub-build (#355) for +one `cp`. + +The engine has the copier. `mcpp stage --output ` is the +subcommand every `stage_file` edge in `build.ninja` already runs +(`ninja_backend.cppm:841`): it creates the destination's parent, compares +content and writes only on difference, which is what makes `restat = 1` +worth having, and it is on disk wherever mcpp runs. The `check` role is +wrapped with `mcpp __action-stamp` for exactly this argument +(`cli.cppm:920`). + +What is missing is the way for an action to **name the engine**. `$mcpp` is +a ninja variable and an action's tokens are ninja-escaped, so a member +cannot spell it. The substitution family an action's argv already has +(`${mcpp.out_dir}`, `${mcpp.bin_dir}`, `${mcpp.compile_db}`, +`${mcpp.stage_dir}`, `${mcpp.target_file:NAME}`, `prepare.cppm:11138`) is +where the answer goes: `${mcpp.self}`, replaced by the engine's own absolute +path, the same `mcpp_exe_path()` the check wrapper bakes in and with the +same caveat (a version change regenerates `build.ninja`; moving the binary +without changing its version leaves a stale path, exactly as for the +compiler). The review confirmed the precedent from an existing artifact: the +`build.ninja` of the wasm probe already carries `mcpp __action-stamp` as the +engine's absolute path, so the token adds a second reader of a value the +graph already holds and no new kind of value. + +The name: `self` is the word the engine already uses for itself in the +`mcpp self version` / `mcpp self doctor` family, so a reader knows what it +names. `${mcpp.bin}` collides with `${mcpp.bin_dir}`; `${mcpp.engine}` has no +precedent. + +### 4.2 The split + +- **mcpp:** the token, one `rep(...)` line and a row in docs/30's + substitution table (both languages). The path-check skips for tokens + containing `${mcpp.` (`directives.cppm:1141`) already cover it. + **`mcpp stage` becomes a contract in the same change.** The subcommand is + marked internal today ("invoked by ninja", `cli.cppm:844`); the first + member that names it turns its argument shape into something the engine + may not change under a published plugin, or the plugin fails on the old + graph with no message that says why. So `stage --output `, + with `--verify content` spelled out, is written into the same docs/30 + table beside `${mcpp.self}`, and the release that carries both is the + floor the member declares. Spelled out rather than defaulted because the + help text says the default is `size` and the code's default is content + (`stage.cppm:60`), and a contract must not depend on which of the two a + reader believes. +- **mcpp-plugins:** `dist-web`'s two copy steps become + `{ "${mcpp.self}", "stage", "--verify", "content", "--output", dst, src }`; + the POSIX-only note + leaves the header and the README row; the member's floor rises to the + engine release carrying the token. `dist-web` also stops creating + destination directories at plan time, since `stage` does. + +An older engine leaves `${mcpp.self}` literal and the action fails at run +time with a not-found for a path that reads as a token; rule 6's +"compatibility by ignoring" does not reach argv, which is why the floor is +raised rather than the token guarded. + +### 4.3 Criteria + +- The e2e that packs the verified Web row on the Windows shard produces the + same file set the Linux shard does, listed and compared; it runs without + `# requires:` gating. +- The negative direction: a `${mcpp.self}` in an action under the engine one + release earlier appears literally in `build.ninja`'s command line, which + is what the floor exists to prevent; checked once, at merge. +- A second `mcpp pack --format web` with nothing changed copies nothing + (`ninja -n` lists no `WEB FILE` edge), which `cp` could never give and + `stage` gives for free. + +## 5. Ask ½: the Cubism SDKs at tier 2 + +The 2026-09-11 record §9.8 states the ladder: redistribute if the licence +permits, otherwise fetch from upstream with no CN mirror, otherwise locate +what the machine has. The #622 record (§4.4, C5) already places Cubism at +tier 2 by `iphoneos-sdk.lua`'s shape: one anonymous upstream URL, `sha256` +pinned, no CN entry, `licenses` recording both the Open Software License and +the Core's proprietary one. Nothing in the ask departs from that. + +Two things the recipe author measures rather than assumes, because each has +a silent failure mode: + +- The URL is anonymous. Cubism's download page gates on a licence + acceptance; if the archive URL is not fetchable without it, tier 2 is + not reachable and the honest recipe is tier 3. Lib-Live2D's + `fetch_cubism.py` already opens that URL with no credential and checks + the `sha256`, which is a measurement and belongs in the recipe's comment + as its evidence; the rule still asks for one install in the sandbox from a + clean home, because a script that works on the author's machine is the + reading the sandbox exists to separate from the general case. +- `licenses` is a set and a wrong member is worse than none. Both licences + are named; the Core's is not "proprietary" as a placeholder but the + licence's own name. + +Native and Web are two packages with two payloads and two PRs, by the index's +one-package-one-identity rule. Neither blocks a HuxerUI row; Lib-Live2D +consumes them when they exist and its fetch script is the interim. + +## 6. Order and repositories + +| step | repository | what lands | unblocks | +|---|---|---|---| +| 1 | mcpp | §2.5 A, the buffer; §4.1's `${mcpp.self}` and the `stage` contract (§4.2) in the same release | HuxerUI stage 0; `dist-web` on Windows | +| 2 | mcpp-plugins | `dist-apk` template and array (§3), `dist-web` on `stage` (§4.2); one release, floor raised to step 1's engine | the Android stage | +| 3 | xim-pkgindex | the two Cubism recipes | Lib-Live2D | +| 4 | HuxerUI | `hrc:builtin` / `hrc:app` enumerate outputs and emit one `deploy` per file; the rule adapter passes both Java roots, its manifest template and its `java-sources.txt` (§3.3); engine floor raised | its own stage 0 and Android | + +Step 2 depends on 1; 3 depends on nothing; 4 depends on 1 and 2. Nothing +waits on an engine branch: `mcpp::deploy` and the platform floor are in +2026.9.12.3. Two of the asks were filed against mcpp-plugins and one of +those (ask 3) is answered in mcpp first; the table is what the issues +should say. + +**What HuxerUI does while it waits.** The bridging action of §2.6 is not +built; it stays in this record as the fallback. The parts that depend on +none of the four proceed: closing its PR #6, everything in stage 0 except +the `deploy` step, Web, and the iOS simulator row. + +## 7. What this record does not propose + +- A manifest merger. The project writes the whole file; the member fills the + values only it knows. +- A bound on the growable buffer. The engine bounds no other directive. +- Guarding `${mcpp.self}` for older engines. The floor is the mechanism. +- Changing `options::resources` (§3.4), the command-axis OS limits (§2.4), or + a general "copy" action role. Each would be its own record. + +## 8. Review (2026-09-13) + +The four conclusions were accepted as written. Three decisions were put to +the reviewer and answered: + +1. **Required tokens** (§3.2): accepted over render-then-check, with the + wording corrected from "by construction" to "the token appears", and the + refusal message and default template extended as §3.2 now says. +2. **Ask 3 in two steps, the issue moved to mcpp** (§4): accepted, with the + addition that `mcpp stage`'s argument shape enters the contract and the + floor with the token, not after it. +3. **The name `${mcpp.self}`**: accepted, for the reason §4.1 now records. + +Four additions came from the review and are folded in above: + +- The engine's absolute path is already in `build.ninja` (`__action-stamp`), + checked on the wasm probe's graph; the byte-identity claim of §2.5 was + checked against existing artifacts and holds (§4.1). +- The path-dependency corner of the Java re-run rule, and HuxerUI's answer + to it (§3.3). +- Lib-Live2D's fetch script as recorded evidence for the Cubism URL (§5). +- A11 was already landed; the earlier draft's step that waited for it is + removed (§6). + +### 8.1 What implementation changed (mcpp side, 2026-09-13) + +- **The count criterion of M5 read the whole file and reported 2N.** A + source action's outputs appear twice in `build.ninja`: on the action's own + edge and again as the inputs of the package's ordering phony. The fixture + now reads only the `build ...: mcpp_action_` line; the first run of the + fixture is what surfaced the second listing. +- **`add()` now escapes control characters as `esc()` does.** The fixed-array + revision passed them through, which produced a payload that was not JSON + and was refused as malformed; so no payload the engine ever accepted + contained one, and the byte-identity claim of §2.5 holds for every + accepted payload. Measured: the cache record of a three-entry action is + byte-identical (1088 bytes) under 2026.9.12.4 and under the change. +- **The reverse legs.** Under 2026.9.12.4, e2e 659 is refused with "arguments + did not fit" and e2e 660 fails with `${mcpp.self}: not found` from the + shell ninja handed the literal token to. Both recorded in the PR. +- **`mcpp stage`'s help said the default was `size`; the code's default is + content.** The help now states the shape the docs/30 row quotes. The + subcommand stays out of the top-level usage list, since nobody types it; + the contract is the argument shape, not its place in `--help`. +- **The long-command leg of M5 is skipped on Windows only**, and the reason is + §2.4's last row: ninja runs every command through `cmd /c` there, whose + 8191-character cap is the operating system's limit on the tool's argv. The + wide-list leg runs on every shard. + +One observation stands as recorded and not acted on: `options::resources` +as a single directory declared as an input (§3.4). It affects HuxerUI +little today, since the framework's Android library carries no `res/` and +only the application has one root, and it is its own item. + +## 9. The task list + +One pull request per repository carries everything that repository owes; a +split is made only where a release boundary forces it (mcpp-plugins can only +be green against a released engine). Each task names its files, its +criterion in both directions, and what it waits on. "Done" for the whole is +§9.5, not any row. + +### 9.1 mcpp, one PR (release 2026.9.13.1) + +| id | task | files | criterion | waits on | +|---|---|---|---|---| +| M1 | `mcpp::action`: six arrays become one growable std-free buffer type; `add()` O(1) amortised; `overflow_` means allocation failure; comment at line 248 restated as the two constraints of §2.2 | `src/build/hostprogram.cppm` | e2e M5; byte identity M6 | — | +| M2 | `action_error`'s overflow message rewritten for allocation failure | `modules/buildmcpp/src/directives.cppm` | unit test: the marker still refuses, the message no longer names a buffer size | — | +| M3 | `${mcpp.self}` in the argv substitution family, replaced by `mcpp_exe_path()` | `src/build/prepare.cppm` | e2e M7 | — | +| M4 | `mcpp stage` promoted from internal to contract: CLI help states the argument shape and the content default correctly (`--verify` help text says `size (default)`; the code default is content, `stage.cppm:60`) | `src/cli.cppm` | `mcpp stage --help` names `--verify content` as the default; the docs row of M8 quotes the same shape | — | +| M5 | e2e: an action with 200 outputs and a command past 16 KiB builds; `build.ninja` lists exactly 200 outputs on that edge; all 200 exist; a program-cache replay after an unrelated source is added keeps the edge complete; no `# requires:` gate | `tests/e2e/659_*.sh` | reverse leg: the same fixture at `baa89d2b` is refused with the overflow diagnostic, run once and recorded in the PR | M1 | +| M6 | byte identity: the `mcpp:action=` line of e2e 188's fixture, captured under `baa89d2b` and under M1, compared with `cmp` | PR body | equal | M1 | +| M7 | e2e: an `artifact` action `${mcpp.self} stage --verify content --output ` copies a file on every shard; a second build copies nothing; no `# requires:` gate | `tests/e2e/660_*.sh` | reverse leg: under `baa89d2b` the token stays literal in `build.ninja` | M3, M4 | +| M8 | docs/30 en and zh: the action section states the list fields have no declared limit and the command is bounded by the OS at run time; the substitution table gains `${mcpp.self}` with the `stage` shape beside it; the "did not fit" advice is removed | `docs/30-build-mcpp.md`, `docs/zh/30-build-mcpp.md` | `check_docs_structure.sh`, `check_docs_style.sh` | M1, M3, M4 | +| M9 | CHANGELOG entry; version bump to 2026.9.13.1 in `mcpp.toml` and `modules/versioning/src/version.cppm` (same commit); `check_version_pins.sh` | as named | `01_help_and_version.sh` | M1 to M8 | +| M10 | this record: status to landed, §8 extended with what implementation changed | this file | index regenerated | M9 | + +### 9.2 mcpp-plugins, one PR (release 0.9.0) + +| id | task | files | criterion | waits on | +|---|---|---|---|---| +| P1 | `dist-apk`: `options::manifest_template`; six tokens; the three required ones refused by name with their downstream reader; unknown token refused; default template is the 0.8.0 manifest in token form with the required tokens marked | `dist/apk.cppm` | §3.5 first two bullets, in `tests/apk-consumer` (level 0 default: byte-identical manifest; a template with a permission and a receiver: `aapt2 dump xmltree` lists both; a template missing `{{application_id}}`: refused, message names the token and `mcpp-run.json`) | — | +| P2 | `dist-apk`: `java_sources` becomes a vector; one `javac`, one `d8`; `rerun_if_changed_glob` only for roots under `manifest_dir()` | `dist/apk.cppm` | §3.5 third bullet: two roots, one dex with classes from both; a `.java` added under the project root re-runs the program | — | +| P3 | `dist-web`: copy steps use `${mcpp.self} stage --verify content --output`; plan-time `create_directories` removed; the POSIX-only note leaves header and README; floor raised | `dist/web.cppm`, `README.md` | `tests/web-consumer/check-web-plan.sh` unchanged and green; second `pack` copies nothing (`ninja -n` lists no `WEB FILE` edge) | M-release | +| P4 | README rows for `dist-apk` and `dist-web`; `mcpp.toml` version 0.9.0; CI `MCPP_VERSION` to 2026.9.13.1 | `README.md`, `mcpp.toml`, `.github/workflows/ci.yml` | CI green on all three runners | M-release | +| P5 | tag `v0.9.0`; source archive to `mcpp-res/mcpp-plugins` on GitCode; sha256 recomputed from both ends and equal | release | GET on both URLs returns the archive; `sha256` equal | P4 merged | +| P6 | mcpp-index: `pkgs/m/mcpp.plugins.lua` gains 0.9.0 and `latest` moves | mcpp-index PR | index artifact published; `mcpp add` resolves 0.9.0 | P5 | + +### 9.3 xim-pkgindex, one PR + +| id | task | files | criterion | waits on | +|---|---|---|---|---| +| X1 | `cubism-sdk-native` 5-r.5 and `cubism-sdk-web` 5-r.5 at tier 2: the upstream URL, `sha256` from Lib-Live2D's pinned values recomputed after an anonymous download, no CN entry, `licenses` naming both licences by their own names read from the archive | `pkgs/c/cubism-sdk-native.lua`, `pkgs/c/cubism-sdk-web.lua`, `tests/c/` | the index's own recipe tests; an install in the sandbox from a clean home; the header states the tier and why | — | + +### 9.4 Verification, in the sandbox + +| id | task | criterion | +|---|---|---| +| V1 | `xlings subos new eco-2026-9-13 --sandbox`, CN mirror set for mcpp and xlings; `xim:mcpp@2026.9.13.1` from the index reports its version | the version line, from the store path | +| V2 | `mcpp:plugins@0.9.0` from the index: `tests/web-consumer` packs and the copy steps carry the engine path; `tests/apk-consumer` at level 0 and with a template | listings, not "it ran" | +| V3 | a consumer with 200 declared outputs builds through the released engine | M5's fixture, against the installed engine | +| V4 | `xim add cubism-sdk-native` and `-web` install from the anonymous URL | the extracted tree's `LICENSE` files exist | + +### 9.5 Done + +The index's `main` names 2026.9.13.1 as `latest` for `xim:mcpp` and 0.9.0 for +`mcpp:plugins`; V1 to V4 pass in the sandbox; this record and the two +repositories' READMEs say what shipped. HuxerUI's step is theirs and is not +in this list. diff --git a/.agents/docs/README.md b/.agents/docs/README.md index 536e8c4c..989b7f15 100644 --- a/.agents/docs/README.md +++ b/.agents/docs/README.md @@ -18,12 +18,16 @@ superseded_by: 2026-09-07-....md # when status is superseded --- ``` -282 records. +283 records. ## By subject Records that declare one. Everything else is listed by date below. +### build-program + +- [Four upstream asks from a UI framework: what each one is under mcpp's design, and the combined plan](2026-09-13-four-upstream-asks-from-a-ui-framework.md) — active + ### docs - [The documentation as a book: a chapter-by-chapter design](2026-09-08-the-documentation-as-a-book.md) — active @@ -61,6 +65,7 @@ Records that declare one. Everything else is listed by date below. ### 2026-09 +- [Four upstream asks from a UI framework: what each one is under mcpp's design, and the combined plan](2026-09-13-four-upstream-asks-from-a-ui-framework.md) — active - [The engine gaps left open after the SDK batch](2026-09-12-engine-gaps-after-the-sdk-batch.md) — landed - [A verified Web run that asked the host for node](2026-09-12-a-verified-web-run-that-asked-the-host-for-node.md) — landed - [Implementation plan: a UI framework on Android, iOS and Web (#622)](2026-09-12-622-implementation-plan.md) — landed diff --git a/CHANGELOG.md b/CHANGELOG.md index df8becb8..fcffd813 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,29 @@ ## [Unreleased] +### `mcpp::action` 的列表不再有长度上限;`${mcpp.self}` 让 action 叫出引擎自己 + +内置的 `mcpp` 模块此前把一个 action 的 `inputs`、`outputs`、`command` 等六个列表 +放在定长数组里(`inputs` 与 `outputs` 各 8192 字节的序列化 JSON),放不下的声明 +被拒绝为「arguments did not fit」。上限按字节计,于是一个消费者的 checkout 深度 +决定了 44 个资源文件的列表能否通过(HuxerUI#130 量得的余量是 45 字节);而 +`outputs` 是作者无法缩短的那一个列表:没有点名的产物无法构建,也不存在面向 +outputs 的 depfile。六个数组换成模块内部基于 `realloc` 增长的 std-free 缓冲, +导出接口、协议版本与原本放得下的 action 的序列化字节全部不变(缓存键因此不变)。 +`"overflow":true` 标记保留,含义改为分配失败,引擎侧消息随之重写。运行期的命令 +仍受操作系统 argv 上限约束,那是工具自己命令行的事。 + +`${mcpp.self}` 加入 action argv 的替换家族,替换为引擎自己的绝对路径。action 的 +命令是没有 shell 的 argv,构建程序此前没有可移植的拷贝手段;`${mcpp.self} stage +--verify content --output ` 即每条 `stage_file` 边已在执行的那次拷贝。 +`mcpp stage` 的参数形状自此成为契约,其帮助文本改为陈述真实的默认值(content)。 + +- 判据:`tests/e2e/659`(200 个输入与 200 个输出的 action 整体进入 build.ninja, + 缓存回放后仍完整;在 2026.9.12.4 上同一夹具被拒绝),`tests/e2e/660` + (`${mcpp.self}` 在每个分片上完成一次拷贝,空转重建不再拷贝;在 2026.9.12.4 + 上 token 原样落入 build.ninja)。 +- 设计记录:`.agents/docs/2026-09-13-four-upstream-asks-from-a-ui-framework.md`。 + ### wasm 产物契约:启动器改名为 `.js`,`.wasm` 是隐式输出(#622 A5) `wasm32-emscripten` 行此前用的是宿主借来的裸名 —— `bin/`(Linux 宿主)或 diff --git a/docs/30-build-mcpp.md b/docs/30-build-mcpp.md index fb096f39..0b755fa8 100644 --- a/docs/30-build-mcpp.md +++ b/docs/30-build-mcpp.md @@ -553,6 +553,18 @@ and the module graph during prepare, so an output whose *name* is unknown cannot be built. Content may arrive later; names may not. A malformed action is a hard error, never a silent skip. +**The lists have no declared size limit** (2026.9.13.1+). `inputs`, `outputs` +and `command` grow with what is declared; a generated tree of two hundred files +is two hundred `output` calls. Until 2026.9.13.1 the bundled module held each +list in a fixed array (8192 bytes of serialised JSON for `inputs` and +`outputs`) and refused a declaration that did not fit, so a consumer's +checkout depth decided whether a list of forty files was accepted. What +remains bounded is the **command at run time**, by the operating system's +limit on a process's arguments (about 128 KiB per argument on Linux, 32767 +characters on Windows, 8191 through `cmd.exe`); that is a limit on the tool's +own command line, and a tool that takes hundreds of files takes them through +a response file or a directory argument of its own. + For a generated **module interface**, declare its interface too: ```cpp @@ -705,6 +717,7 @@ none to rely on), and the only interpolations are a closed set: | `${mcpp.compile_db}` | path to `compile_commands.json` (what clang-tidy's `-p` wants) | | `${mcpp.target_file:}` | the built file of target `` | | `${mcpp.stage_dir}` *(2026.9.11.1+)* | the tree `mcpp pack` staged, absolute. `artifact` role only, and only under `mcpp pack --format ` | +| `${mcpp.self}` *(2026.9.13.1+)* | the engine's own executable, absolute. An action's command is an argv with no shell, so a build program has no portable way to copy a file; the engine is present wherever a build runs, and `${mcpp.self} stage --verify content --output ` copies one file, creates the destination's parent, and writes only when the bytes differ. That argument shape is a contract from 2026.9.13.1 on; a build program that names it declares that release as its floor | The raw stdout protocol above remains the low-level substrate; `import mcpp;` is the typed layer over it. diff --git a/docs/zh/30-build-mcpp.md b/docs/zh/30-build-mcpp.md index d017b5b7..7fd3f164 100644 --- a/docs/zh/30-build-mcpp.md +++ b/docs/zh/30-build-mcpp.md @@ -470,6 +470,15 @@ mcpp 为那条边写出 `depfile =` 与 `deps = gcc`,ninja 读取该文件并把 所以名字未知的产物无法构建。内容可以晚到,名字不行。畸形 action 是**硬错误**, 绝不静默跳过。 +**列表没有声明上的长度上限**(2026.9.13.1+)。`inputs`、`outputs` 与 `command` +随声明增长;一棵两百个文件的生成树就是两百次 `output` 调用。2026.9.13.1 之前, +内置模块把每个列表放在定长数组里(`inputs` 与 `outputs` 各 8192 字节的序列化 +JSON),放不下的声明会被拒绝,于是一个消费者的 checkout 深度决定了四十个文件的 +列表能否被接受。仍然有上限的是**运行期的命令**,由操作系统对进程参数的限制决定 +(Linux 上每个参数约 128 KiB,Windows 上 32767 个字符,经 `cmd.exe` 则是 8191); +那是工具自己命令行的上限,一个要接收几百个文件的工具用它自己的 response file +或目录参数来接收。 + 生成**模块接口**时,把它的接口也声明出来: ```cpp @@ -606,6 +615,7 @@ mcpp 会写出 `<暂存树>.stage-manifest` —— 一个兄弟文件,永不是 | `${mcpp.compile_db}` | `compile_commands.json` 的路径(clang-tidy 的 `-p` 要的就是它) | | `${mcpp.target_file:}` | target `` 构建出的文件 | | `${mcpp.stage_dir}` *(2026.9.11.1+)* | `mcpp pack` 暂存出的那棵树,绝对路径。仅 `artifact` role 可用,且仅在 `mcpp pack --format ` 下可用 | +| `${mcpp.self}` *(2026.9.13.1+)* | 引擎自己的可执行文件,绝对路径。action 的命令是没有 shell 的 argv,构建程序因此没有可移植的拷贝手段;引擎在构建运行的每台机器上都在,`${mcpp.self} stage --verify content --output ` 拷贝一个文件、创建目标的父目录、只在字节不同时写入。这个参数形状自 2026.9.13.1 起是契约;写下它的构建程序即以该版本为下限 | 上面的裸 stdout 协议仍是底层基底;`import mcpp;` 是其上的类型化层。 From ef96d8298c47a57fb4e64c386f3f9ed3d5c922ff Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Sun, 13 Sep 2026 03:37:32 +0800 Subject: [PATCH 4/6] chore: bump version to 2026.9.13.1 --- mcpp.toml | 2 +- modules/versioning/src/version.cppm | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mcpp.toml b/mcpp.toml index d2b09b4e..bf2ddfa9 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.9.12.4" +version = "2026.9.13.1" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/modules/versioning/src/version.cppm b/modules/versioning/src/version.cppm index 9b20a4d6..9ac984c3 100644 --- a/modules/versioning/src/version.cppm +++ b/modules/versioning/src/version.cppm @@ -31,6 +31,6 @@ import std; export namespace mcpp { -inline constexpr std::string_view MCPP_VERSION = "2026.9.12.4"; +inline constexpr std::string_view MCPP_VERSION = "2026.9.13.1"; } // namespace mcpp From 88bd3136df7a0048990d0ffc4079995f13d9cad4 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Sun, 13 Sep 2026 04:01:15 +0800 Subject: [PATCH 5/6] ninja: the command-length guard measures a literal command, not its edge line check_inline_command_lengths read each build line as a proxy for the command. That is right for a rule that expands $in and $out and wrong for an action rule, whose command is a literal argv and whose inputs and outputs exist only so that ninja can order and re-run it. e2e 659's first run on the Windows shard refused an action with 200 outputs and 200 inputs with the whole edge line counted as argv. The guard now measures a literal command's own text. The refusal names the edge by its first output and the count of the rest, instead of printing every output. e2e 659 declares 600 inputs and 600 outputs, so the edge line crosses the POSIX 128 KiB limit as well as Windows's 32767 and the guard is measured on every shard; its negative leg declares a 140800-byte literal command and expects the refusal to name the edge. --- ...-four-upstream-asks-from-a-ui-framework.md | 20 ++++-- CHANGELOG.md | 10 ++- docs/30-build-mcpp.md | 10 +-- docs/zh/30-build-mcpp.md | 7 +- src/build/ninja_backend.cppm | 31 +++++++- ...an_action_declaration_has_no_size_limit.sh | 70 ++++++++++++++++--- 6 files changed, 122 insertions(+), 26 deletions(-) diff --git a/.agents/docs/2026-09-13-four-upstream-asks-from-a-ui-framework.md b/.agents/docs/2026-09-13-four-upstream-asks-from-a-ui-framework.md index 4b9c5757..f30d1978 100644 --- a/.agents/docs/2026-09-13-four-upstream-asks-from-a-ui-framework.md +++ b/.agents/docs/2026-09-13-four-upstream-asks-from-a-ui-framework.md @@ -519,10 +519,22 @@ Four additions came from the review and are folded in above: content.** The help now states the shape the docs/30 row quotes. The subcommand stays out of the top-level usage list, since nobody types it; the contract is the argument shape, not its place in `--help`. -- **The long-command leg of M5 is skipped on Windows only**, and the reason is - §2.4's last row: ninja runs every command through `cmd /c` there, whose - 8191-character cap is the operating system's limit on the tool's argv. The - wide-list leg runs on every shard. +- **The long-command leg of M5 runs on POSIX hosts only**, because its + command is `true`, a program that accepts any argv, and Windows has none. + An earlier draft of this bullet said ninja runs every command through + `cmd /c` on Windows; it does not (#261 removed the last rule that needed a + shell), and a plain argv goes through `CreateProcess` with its 32767 + characters. The wide-list leg runs on every shard. +- **The engine's command-length guard refused the wide action on the Windows + shard.** `check_inline_command_lengths` read each `build` line as a proxy + for the command, which is right for a rule that expands `$in` and `$out` + and wrong for an action rule, whose command is a literal argv; 200 outputs + and 200 inputs of Windows temp paths put the line over 32767. That is + §2.4's claim ("edge lists, never an argv") not holding inside one guard. + The guard now measures a literal command's own text; e2e 659's N was + raised to 600 so that the edge line also crosses the POSIX 128 KiB limit, + and the fixture holds the guard on every shard. Reverse leg: the raised + fixture is refused by the engine before the guard fix on Linux too. One observation stands as recorded and not acted on: `options::resources` as a single directory declared as an input (§3.4). It affects HuxerUI diff --git a/CHANGELOG.md b/CHANGELOG.md index fcffd813..c96783c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,8 +22,14 @@ outputs 的 depfile。六个数组换成模块内部基于 `realloc` 增长的 s --verify content --output ` 即每条 `stage_file` 边已在执行的那次拷贝。 `mcpp stage` 的参数形状自此成为契约,其帮助文本改为陈述真实的默认值(content)。 -- 判据:`tests/e2e/659`(200 个输入与 200 个输出的 action 整体进入 build.ninja, - 缓存回放后仍完整;在 2026.9.12.4 上同一夹具被拒绝),`tests/e2e/660` +顺带修正引擎的命令长度守卫:它此前把每条 `build` 行读成命令的代理,这对展开 +`$in`/`$out` 的规则成立,对命令是字面 argv 的 action 规则不成立 —— 一个 200 个 +输出的 action 在 Windows 分片上被整条边行当 argv 计数而拒绝。守卫现在对字面命令 +量它自己的文本。 + +- 判据:`tests/e2e/659`(600 个输入与 600 个输出的 action 整体进入 build.ninja, + 边行越过每个宿主的 argv 上限而不被守卫误拒,缓存回放后仍完整;在 2026.9.12.4 + 上同一夹具被拒绝),`tests/e2e/660` (`${mcpp.self}` 在每个分片上完成一次拷贝,空转重建不再拷贝;在 2026.9.12.4 上 token 原样落入 build.ninja)。 - 设计记录:`.agents/docs/2026-09-13-four-upstream-asks-from-a-ui-framework.md`。 diff --git a/docs/30-build-mcpp.md b/docs/30-build-mcpp.md index 0b755fa8..35db532b 100644 --- a/docs/30-build-mcpp.md +++ b/docs/30-build-mcpp.md @@ -560,10 +560,12 @@ list in a fixed array (8192 bytes of serialised JSON for `inputs` and `outputs`) and refused a declaration that did not fit, so a consumer's checkout depth decided whether a list of forty files was accepted. What remains bounded is the **command at run time**, by the operating system's -limit on a process's arguments (about 128 KiB per argument on Linux, 32767 -characters on Windows, 8191 through `cmd.exe`); that is a limit on the tool's -own command line, and a tool that takes hundreds of files takes them through -a response file or a directory argument of its own. +limit on a process's arguments (128 KiB per argument on Linux, 32767 +characters for a Windows `CreateProcess`); that is a limit on the tool's own +command line, and a tool that takes hundreds of files takes them through a +response file or a directory argument of its own. The engine's own guard +against it measures the command, not the edge: an action's inputs and +outputs are graph edges, never argv. For a generated **module interface**, declare its interface too: diff --git a/docs/zh/30-build-mcpp.md b/docs/zh/30-build-mcpp.md index 7fd3f164..062d4d57 100644 --- a/docs/zh/30-build-mcpp.md +++ b/docs/zh/30-build-mcpp.md @@ -475,9 +475,10 @@ mcpp 为那条边写出 `depfile =` 与 `deps = gcc`,ninja 读取该文件并把 内置模块把每个列表放在定长数组里(`inputs` 与 `outputs` 各 8192 字节的序列化 JSON),放不下的声明会被拒绝,于是一个消费者的 checkout 深度决定了四十个文件的 列表能否被接受。仍然有上限的是**运行期的命令**,由操作系统对进程参数的限制决定 -(Linux 上每个参数约 128 KiB,Windows 上 32767 个字符,经 `cmd.exe` 则是 8191); -那是工具自己命令行的上限,一个要接收几百个文件的工具用它自己的 response file -或目录参数来接收。 +(Linux 上每个参数 128 KiB,Windows 的 `CreateProcess` 是 32767 个字符);那是 +工具自己命令行的上限,一个要接收几百个文件的工具用它自己的 response file 或目录 +参数来接收。引擎自己针对它的守卫量的是命令而不是边:action 的输入与输出是图上的 +边,从不进入 argv。 生成**模块接口**时,把它的接口也声明出来: diff --git a/src/build/ninja_backend.cppm b/src/build/ninja_backend.cppm index 45f06003..8d2ade09 100644 --- a/src/build/ninja_backend.cppm +++ b/src/build/ninja_backend.cppm @@ -2838,6 +2838,15 @@ std::optional check_rule_commands_name_a_program( std::optional check_inline_command_lengths(const std::string& manifest) { std::set rspRules; + // A rule whose command names neither `$in` nor `$out` puts a FIXED string + // on the command line, however many files its edges list: the declared + // action rules (`mcpp_action_`) are built this way, their argv baked + // into the rule and their inputs and outputs present only so that ninja + // can order and re-run them. For those the edge line is not a proxy for + // the command -- an action with two hundred outputs was refused on + // Windows with the whole list counted as argv (e2e 659, 2026-09-13) -- + // so the command text itself is what gets measured. + std::map literalCommand; std::string current; for (auto line : manifest | std::views::split('\n')) { std::string_view l{line.begin(), line.end()}; @@ -2846,6 +2855,11 @@ std::optional check_inline_command_lengths(const std::string& manif } else if (!current.empty() && l.find("rspfile") != std::string_view::npos && l.find("rspfile_content") == std::string_view::npos) { rspRules.insert(current); + } else if (!current.empty() && l.starts_with(" command = ")) { + auto cmd = l.substr(std::string_view(" command = ").size()); + if (cmd.find("$in") == std::string_view::npos + && cmd.find("$out") == std::string_view::npos) + literalCommand[current] = std::string(cmd); } else if (l.empty()) { current.clear(); } @@ -2867,13 +2881,26 @@ std::optional check_inline_command_lengths(const std::string& manif if (rule == "phony") continue; // `sh -c` on POSIX; on windows nothing needs a shell since #261. + std::string_view measured = l; + if (auto lit = literalCommand.find(rule); lit != literalCommand.end()) + measured = lit->second; auto over = mcpp::build::cmdlimits::check_inline( - l, mcpp::platform::is_windows, /*needsShell=*/!mcpp::platform::is_windows); + measured, mcpp::platform::is_windows, /*needsShell=*/!mcpp::platform::is_windows); if (!over) continue; + // The edge is named by its FIRST output and the count of the rest: + // an edge with hundreds of outputs would otherwise print every one + // of them into a diagnostic whose point is to name the edge. auto out = l.substr(6, colon - 6); + std::size_t nOut = 0; + for (auto tok : out | std::views::split(' ')) + if (!std::string_view{tok.begin(), tok.end()}.empty()) ++nOut; + std::string_view first = out.substr(0, out.find(' ')); + std::string named = nOut > 1 + ? std::format("{} (and {} more outputs)", first, nOut - 1) + : std::string(first); return mcpp::build::cmdlimits::explain( - std::format("build edge '{}' (rule {})", out, rule), *over); + std::format("build edge '{}' (rule {})", named, rule), *over); } return std::nullopt; } diff --git a/tests/e2e/659_an_action_declaration_has_no_size_limit.sh b/tests/e2e/659_an_action_declaration_has_no_size_limit.sh index 22091558..3953155d 100755 --- a/tests/e2e/659_an_action_declaration_has_no_size_limit.sh +++ b/tests/e2e/659_an_action_declaration_has_no_size_limit.sh @@ -26,11 +26,23 @@ # producing files is the command's business, covered by 188. What is under # test here is the declaration channel. # -# A second action carries a command past 16384 bytes on the hosts whose OS -# allows it. Windows is skipped for that leg only: ninja runs every command -# through `cmd /c` there, which caps a command line at 8191 characters, and -# that is the operating system's limit on the tool's own argv, not the -# engine's on the declaration (the design record's section 2.4). +# N is chosen so that the action's EDGE LINE in build.ninja (outputs, rule, +# inputs) is longer than the POSIX per-argument limit of 128 KiB as well as +# Windows's 32767-character CreateProcess limit. That is the second thing this +# fixture measures: the engine's command-length guard used to read the edge +# line as a proxy for the command, which is right for a rule that expands +# `$in` and `$out` and wrong for an action rule, whose command is a literal +# argv and whose inputs and outputs exist only so that ninja can order and +# re-run it. Its first run on the Windows shard refused this fixture with the +# whole list counted as argv; the guard now measures a literal command's own +# text, and the same fixture is what holds that on every shard. +# +# A second action carries a command past 16384 bytes, the old bound of the +# command list, on the POSIX hosts: its command is `true`, a program that +# accepts any argv and exits 0, which Windows does not have. 19200 bytes is +# under every operating system's limit on a process's arguments; what remains +# bounded at run time is the tool's own argv, by the OS (the design record's +# section 2.4), and that is not the engine's bound on the declaration. set -e source "$(dirname "$0")/_host_path.sh" @@ -39,7 +51,7 @@ TMP=$(mktemp -d) trap "rm -rf $TMP" EXIT cd "$TMP" -readonly N=200 +readonly N=600 mkdir -p app/src app/in cd app @@ -61,17 +73,19 @@ EOF # N input files, so that ninja finds every declared input on disk. Each path # is padded to a fixed width with a long directory name, so the serialised -# list crosses the old bound regardless of where this fixture runs: -# N * (width + 3) > 8192 whenever width >= 40. -PAD="a-directory-name-long-enough-to-make-the-list-cross-the-old-bound" +# lists cross the old 8192-byte bound and the edge line crosses 128 KiB +# regardless of where this fixture runs: with a 100-byte directory name each +# path is at least 115 bytes, and 600 inputs plus 600 outputs put more than +# 138000 bytes on the one edge line. +PAD="a-directory-name-long-enough-to-make-the-edge-line-cross-the-per-argument-limit-of-the-host-os-xxxx" mkdir -p "in/$PAD" for i in $(seq 1 $N); do : > "in/$PAD/input-$i.txt"; done ROOT_HOST=$(host_path "$PWD") MCPP_HOST=$(host_path "$(cd "$(dirname "$MCPP")" && pwd)/$(basename "$MCPP")") -# The long-command leg: a program that accepts any argv and exits 0. Not on -# Windows (see the header). +# The long-command leg: a program that accepts any argv and exits 0. POSIX +# hosts only (see the header). TRUE_HOST="" case "$(uname -s)" in MINGW* | MSYS* | CYGWIN*) ;; @@ -158,4 +172,38 @@ outs=$(count_in_edge "output-[0-9]*\.txt") [ "$outs" = "$N" ] || { echo "FAIL: after a replay build.ninja names $outs of $N declared outputs"; exit 1; } echo "ok: the replayed record carries all $N outputs" +# The negative direction of the guard: a LITERAL command that really is too +# long for the host is still refused, before anything is compiled, and the +# refusal names the edge by its first output rather than by every output. +# 2200 arguments of 64 bytes is 140800 bytes, over the POSIX 128 KiB +# per-argument limit and over Windows's 32767 alike, so this leg runs on +# every shard. The program named does not have to exist: the guard fires +# while build.ninja is being written. +mkdir -p ../too-long/src +cp mcpp.toml ../too-long/ && cp src/main.cpp ../too-long/src/ +cd ../too-long +cat > build.mcpp < b4.log 2>&1; then + cat b4.log; echo "FAIL: a 140800-byte literal command was accepted"; exit 1 +fi +grep -q "over the .* byte limit" b4.log || { cat b4.log; echo "FAIL: the refusal is not the command-length guard's"; exit 1; } +grep -q "too-long.stamp" b4.log || { cat b4.log; echo "FAIL: the refusal does not name the edge"; exit 1; } +# One output, so no "(and N more outputs)" suffix; the whole message stays +# short enough to read. +[ "$(wc -c < b4.log)" -lt 4000 ] || { wc -c b4.log; echo "FAIL: the refusal printed the whole edge line"; exit 1; } +echo "ok: a command over the host limit is refused, naming the edge" + echo "OK" From 3a4c04aced950797a5633dabbaf3d2f3d31ed456 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Sun, 13 Sep 2026 04:28:04 +0800 Subject: [PATCH 6/6] ci(openkal-cross): say what the Windows host provides for the mingw target The Windows job of this workflow was green with one sandbox lineage and red with the next on the same sources and the same runner image (PR #629: lld could not find -lntdll and its neighbours). On a Windows host openkal-windows's build program generates no import libraries, so where lld finds them is a property of the host, the sandbox or the payload, and nothing in the job said which. This step prints it. --- .github/workflows/openkal-cross.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/.github/workflows/openkal-cross.yml b/.github/workflows/openkal-cross.yml index fe6981b4..323dbdef 100644 --- a/.github/workflows/openkal-cross.yml +++ b/.github/workflows/openkal-cross.yml @@ -187,6 +187,27 @@ jobs: "$MCPP_UNDER_TEST" toolchain install llvm 22.1.8 "$MCPP_UNDER_TEST" toolchain default 'llvm@22.1.8' + # WHAT THIS RUNNER ACTUALLY PROVIDES FOR THE mingw TARGET. On a Windows + # host `openkal-windows`'s build program generates no import libraries + # ("the system's own are present"), so `-lntdll` and its neighbours are + # found by lld only where the host, the sandbox or the payload puts + # them. This job was green with one sandbox lineage and red with the + # next (2026-09-13, PR #629, same sources, same image), which is the + # signature of a dependency on cached state nobody declared. The lines + # below say where the libraries come from, so the next such reading is + # diagnosed from the log rather than from a bisect over caches. + - name: What this host provides for x86_64-w64-windows-gnu + if: matrix.host == 'windows' + run: | + echo "PATH=$PATH" | tr ':' '\n' | head -40 + ls "${MCPP_HOME:-$HOME/.mcpp}/registry/data/xpkgs" 2>/dev/null || echo "(no xpkgs dir)" + CLANG=$(ls "${MCPP_HOME:-$HOME/.mcpp}"/registry/data/xpkgs/xim-x-llvm/22.1.8/bin/clang++.exe 2>/dev/null | head -1) + echo "clang=$CLANG" + [ -n "$CLANG" ] && "$CLANG" --target=x86_64-w64-windows-gnu -print-search-dirs + [ -n "$CLANG" ] && "$CLANG" --target=x86_64-w64-windows-gnu -print-file-name=libntdll.a + [ -n "$CLANG" ] && "$CLANG" --target=x86_64-w64-windows-gnu -print-file-name=libkernel32.a + command -v x86_64-w64-mingw32-gcc gcc 2>/dev/null || true + - name: The program — one source, three targets run: | set -euo pipefail