Skip to content

Packaging parity measured by a framework's build-systems conformance check: strip graph-built libraries (E5), feature tools that depend on their SDK (E6), git sub-packages (E7), dependency features on the command line (E8), a pack report (E9), the llvm row's MSVC runtime (E10) #649

Description

@Sunrisepeak

中文摘要

HuxerUI 给两个构建系统加了一致性验证:用同一个 CLI 分别创建 CMake 与 mcpp 应用、各自打包,比对产物的程序模型事实(六个平台)。修复之后,六个平台在 Sunrisepeak/HuxerUI#7 上全部一致。插件能修的已经发布为 mcpp:plugins 0.11.1(mcpp-community/mcpp-plugins#27)。下面是插件替代不了、需要引擎处理的部分,以及插件侧剩下的两个小项。编号接着 #647 的 E1–E4。

  • E5(缺陷):mcpp pack 不剥离 Android 行的程序,也不剥离图内构建的共享库。
    • 输出写着 "stripped",实测 libapp.solibdep.solibc++_shared.so 都带 .symtab.debug_*
    • 桌面行只剥离可执行文件,图内构建的 libdep.so 同样没剥离;
    • dist-apk 0.11.1 已自己剥离,作为临时方案。
  • E6(缺陷):feature 工具不能依赖声明它的包。
    • 工具构建成功、dep_bin 也拿得到路径,随后应用构建报 dependency cycle through package 'fw' while computing its build-cache key
    • 场景:SDK 以 feature 提供一个依赖自身的程序(Windows 安装界面),各应用就不必再复制一份。
  • E7(需求):git 依赖只能解析仓库根包。
    • 同一仓库里的其他包([workspace] members)选不到;
    • 场景:发布到索引之前,按 git 版本钉一个包含多个包的仓库。
  • E8(需求 + 缺陷):--features 只接受根包 feature。
    • 根包有 [features] 表时,--features dep/feature 被警告后忽略,--strict 下报错;
    • 根包没有 [features] 表时,任何名字都被静默忽略,--strict 也不报错;
    • 场景:CLI 在打包时打开依赖的 feature,不要求每个应用清单都写转发行。
  • E9(需求):mcpp pack 的机器可读产物清单。
    • 现在只能解析 Packed <路径> 文本行,路径还经过缩写(项目相对、@mcpp/~/);
    • 另有一处不一致:pack 不接受 --release / --devbuildrun 都接受。
  • E10(问题):Windows 上 llvm 行的 C/C++ 运行时模型。
    • 实测 mcpp 构建的程序静态链接运行时,不导入 msvcp140.dllvcruntime140.dllapi-ms-win-crt-*
    • 文档只在 cl.exe 一节写了默认 /MD,源码里 CRT 选择只作用于 msvc 方言;
    • 想确认 llvm 行的默认值是不是设计如此,以及 cxx_runtime 能否在这一行选择动态运行时。
  • 插件侧(低优先级):
    • P1:dist-apple 默认写入的键(NSHighResolutionCapableLSRequiresIPhoneOS)只能改值,不能去掉;
    • P2:dist-web 的页面固定叫 index.html

每一项都附实测输出,以及可以在 GitHub 托管 runner 上跑的 CI 检查。


Context

HuxerUI builds with CMake and with mcpp. Its specification (docs/design/build-systems-spec.md on Sunrisepeak/HuxerUI#7) states that a project built with either build system's defaults is the same program. build-systems-conformance.yml checks that on every platform:

  1. The same application is created for each build system.
  2. Both are packaged with huxerui package <platform>.
  3. Facts are read from the two artifacts and compared: the manifest and Info.plist entries, icons, resources with their digests, the libraries and how they are stored, the Burn bundle and MSI contents, and so on.

The first runs found differences in three places.

Measured with mcpp 2026.9.15.2 and mcpp:plugins 0.11.1, on Linux x86_64 unless stated. Source references are to main at 2fc7b5b. The fixtures are throwaway directories, every file is quoted, and the home directory is written as ~.

# What Kind Needed by
E5 mcpp pack strips the program on every row and the shared libraries the graph builds defect APK size parity with Gradle; --no-strip / --debug-symbols reaching every packed library
E6 A feature-gated tool may depend on the package that declares it defect an SDK shipping a program built against itself
E7 A git dependency can name another package in the repository enhancement multi-package repositories pinned by git
E8 --features <dependency>/<feature>; an unknown name is reported with no [features] table enhancement + defect a driver opening a dependency's packaging feature for one command
E9 A machine-readable report from mcpp pack; --release / --dev on pack enhancement tools that publish what mcpp pack produced
E10 The C/C++ runtime model of the llvm row on x86_64-windows-msvc question runtime parity with MSVC builds

E5. mcpp pack leaves an Android row's program, and every graph-built shared library, unstripped

Fixture. An application with a shared dependency, packed for Android and, with a bin target, for the Linux desktop.

# dep/mcpp.toml
[package]
name      = "dep"
namespace = "spike"
version   = "0.1.0"
standard  = "c++20"

[targets.dep]
kind = "shared"

[build]
sources = ["src/*.cpp"]
// dep/src/dep.cpp
#include <string>
[[gnu::visibility("default")]] int dep_answer() { return static_cast<int>(std::to_string(42).size()) + 40; }
# app/mcpp.toml
[package]
name      = "app"
version   = "0.1.0"
standard  = "c++20"

[dependencies]
spike.dep = { path = "../dep" }

[targets.app]
kind = "app"
main = "src/main.cpp"

[target.x86_64-linux-android]
min_api_level = 23
// app/src/main.cpp
#include <string>
int dep_answer();
[[gnu::visibility("default")]] int app_entry() { return dep_answer() + static_cast<int>(std::to_string(1).size()); }
int main() { return app_entry() == 43 ? 0 : 1; }

hostapp/ is the same package with [targets.hostapp] kind = "bin" and int main() { return dep_answer() == 42 ? 0 : 1; }.

Measured: the Android row.

$ mcpp pack --target x86_64-linux-android --format tar
    Resolved android-ndk@30.0.16248370 → x86_64-linux-android → @mcpp/registry/data/xpkgs/xim-x-android-ndk/30.0.16248370/toolchains/llvm/prebuilt/linux-x86_64/bin/clang++
      Target x86_64-linux-android → x86_64-unknown-linux-android23
     Packing app v0.1.0 (vendored, stripped)
      Packed target/dist/app-0.1.0-x86_64-linux-android.tar.gz

In the archive, read with file and readelf -S -W:

file bytes file sections
lib/libapp.so (the program) 5 904 not stripped .symtab, .debug_line
lib/libdep.so (built by this graph) 5 496 not stripped .symtab, .debug_line
lib/libc++_shared.so (the NDK's) 9 091 400 not stripped .symtab, .debug_abbrev, .debug_info, .debug_ranges, .debug_str, .debug_line, .debug_loc

Measured: the Linux desktop row.

$ mcpp pack --format tar
     Packing hostapp v0.1.0 (vendored, stripped)
      Packed target/dist/hostapp-0.1.0-x86_64-linux-gnu.tar.gz
  • bin/hostapp is stripped: no .symtab, no .debug_*.
  • lib/libdep.so is not stripped: it keeps .symtab and 7 .debug_* sections (16 592 bytes).

In an application. HuxerUI's two-ABI APK, arm64-v8a, before dist-apk 0.11.1 stripped (bytes):

library mcpp Gradle
libconformance.so (the program) 123 272 31 512
libhuxerui.so (the framework, a shared library the graph builds) 8 107 904 5 465 256
libc++_shared.so (the NDK's) 9 485 032 1 374 336

The Android Gradle plugin strips every native library it packages with llvm-strip --strip-unneeded, the NDK's libc++_shared.so included.

Cause, partly. strip_program in src/pack/pack.cppm strips the staged program with the Executable shape and nothing else. Its comment gives the reason:

A bundled .so is somebody else's file: it came out of the store or off the host, mcpp did not build it, and stripping it would change a shared payload's bytes for no gain to this bundle.

That reason holds for store and host files. It does not hold for a shared library this graph compiled from source (libdep.so above). On the Android row the program itself is not stripped either, although the line says stripped; that path was not traced.

Why it matters beyond size.

  • dist-apk 0.11.1 now strips with llvm-strip, which it locates itself (mcpp::toolchain_dir()/bin/llvm-strip).
  • mcpp pack --no-strip ("Ship the artifacts as built") and --debug-symbols <DIR> do not reach that step: a build program receives MCPP_PACK_FORMAT and MCPP_PACK_STAGE_DIR, and nothing about the strip decision.
  • --format tar / dir for Android, and every desktop pack with a graph-built shared library, still ship symbols.

Proposed (for discussion).

  • Strip what the graph built: the program on every row, with the SharedLibrary shape when the program is a shared object, and every shared library the graph compiled. Both honour --no-strip and --debug-symbols.
  • Decide the toolchain's own runtime libraries (libc++_shared.so) explicitly. Gradle strips them. Either way the policy should be stated.
  • Expose the resolved decision to build programs (for example mcpp::pack_strip() and mcpp::pack_debug_symbols_dir()), so a dist member that re-packs libraries follows the same switch.

CI check (ubuntu-24.04). The fixture above.

  • mcpp pack --target x86_64-linux-android --format tar gives libapp.so and libdep.so with no .symtab and no .debug_*, and readelf --dyn-syms -W still lists app_entry and dep_answer.
  • mcpp pack --format tar on hostapp/ gives the same for lib/libdep.so.
  • With --no-strip, all of them keep .symtab.

E6. A tool behind a feature cannot depend on the package that declares it

Fixture.

# fw/mcpp.toml
[package]
name      = "fw"
namespace = "spike"
version   = "0.1.0"
standard  = "c++20"

[targets.fw]
kind = "lib"

[build]
sources = ["src/*.cpp"]

[features]
installer = []

[target.'cfg(os = "linux")'.feature-deps.installer]
spike.fw-installer = { path = "tool", tools = ["fw-installer"], reexport = true }
# fw/tool/mcpp.toml
[package]
name      = "fw-installer"
namespace = "spike"
version   = "0.1.0"
standard  = "c++20"

[targets.fw-installer]
kind = "bin"
main = "src/main.cpp"

[dependencies]
spike.fw = { path = ".." }
# app/mcpp.toml
[package]
name      = "app"
version   = "0.1.0"
standard  = "c++20"

[dependencies]
spike.fw = { path = "../fw" }

[features]
windows-installer = ["spike.fw/installer"]

[targets.app]
kind = "bin"
main = "src/main.cpp"
// app/build.mcpp
import std;
import mcpp;

int main() {
    const char* a = mcpp::dep_bin("fw-installer", "fw-installer");
    const char* b = mcpp::dep_bin("spike.fw-installer", "fw-installer");
    const std::string message = std::string("SPIKE short=[") + (a ? a : "") + "] qualified=[" + (b ? b : "") + "]";
    mcpp::warning(message.c_str());
    return 0;
}
// fw/src/fw.cpp
int fw_answer() { return 42; }
// fw/tool/src/main.cpp
int fw_answer();
#include <cstdio>
int main() { std::printf("fw-installer ran %d\n", fw_answer()); return 0; }
// app/src/main.cpp
int fw_answer();
int main() { return fw_answer() == 42 ? 0 : 1; }

Measured.

$ mcpp build --features windows-installer
   Resolving toolchain
    Resolved gcc@16.1.0 → @mcpp/registry/data/xpkgs/xim-x-gcc/16.1.0/bin/g++
    Building host tool fw-installer:fw-installer from fw-installer v0.1.0 (once per package source and host toolchain)
   Resolving toolchain
    Resolved gcc@16.1.0 → @mcpp/registry/data/xpkgs/xim-x-gcc/16.1.0/bin/g++
      Target x86_64-linux-gnu → x86_64-unknown-linux-gnu
      Target x86_64-linux-gnu → x86_64-unknown-linux-gnu
  build.mcpp compiling
  build.mcpp running
warning: app: SPIKE short=[~/.mcpp/build-cache/v1/tool/spike/fw-installer@0.1.0+path.ca13b1af6bea7e50/203ca1e0189df641/bin/fw-installer] qualified=[]
error: dependency cycle through package 'fw' while computing its build-cache key
$ echo $?
2
$ ~/.mcpp/build-cache/v1/tool/spike/fw-installer@0.1.0+path.ca13b1af6bea7e50/203ca1e0189df641/bin/fw-installer
fw-installer ran 42

The tool is built and runs, and the application's build program receives its path. The build then fails computing fw's cache key.

From the same fixture:

Need. HuxerUI's Windows Setup.exe runs an installer interface: a HuxerUI program compiled against the framework with WiX's bootstrapper SDK (<name>-Installer.exe beside mbanative.dll in the bundle).

  • Its source is the same in every application: a seven-line wWinMain and a build program that names the target. Only the application's name differs.
  • Today every application carries that three-file windows/installer/ package: four copies across the framework's templates and an example.
  • The natural shape is the framework providing the program behind a feature. The program depends on the framework, so the declaration is a cycle.

Proposed (for discussion). The tool is already a separate host build, "once per package source and host toolchain", and its graph does not activate fw's installer feature. fw as the tool's dependency (host toolchain, default features) and fw as the application's dependency (target toolchain, installer) are two nodes.

  • Key a package's build per (package, activated features, toolchain). The recursion then ends: the host fw without installer has no tool edge.
  • Where the tool graph really does activate the declaring feature, refuse before building the tool, naming the edge.

CI check (ubuntu-24.04). The fixture above:

  • mcpp build --features windows-installer exits 0;
  • the application's build program prints a non-empty dep_bin path;
  • the tool prints fw-installer ran 42.

E7. A git dependency resolves only the repository's root package

Fixture. A git repository whose root package lists a member:

# repo/mcpp.toml
[package]
name      = "fw"
namespace = "spike"
version   = "0.1.0"
standard  = "c++20"

[targets.fw]
kind = "lib"

[build]
sources = ["src/*.cpp"]

[workspace]
members = ["tool"]

repo/tool/mcpp.toml is spike.fw-installer, as in E6, depending on spike.fw = { path = ".." }. The application names both by git revision:

[dependencies]
spike.fw = { git = "file:///…/repo", rev = "cc3c74c51240615da9f6d7b860e5ee2bef704cbb" }

[features]
windows-installer = []

[target.'cfg(os = "linux")'.feature-deps.windows-installer]
spike.fw-installer = { git = "file:///…/repo", rev = "cc3c74c51240615da9f6d7b860e5ee2bef704cbb", tools = ["fw-installer"] }

Its build program prints mcpp::dep_bin("fw-installer", "fw-installer").

Measured.

$ mcpp build --features windows-installer
warning: 'app' declares the dependency 'spike.fw-installer', which names spike.fw-installer; the manifest '~/.mcpp/git/2493e8e7594755c7/mcpp.toml' declares spike.fw, and that identity is used.
  hint: write 'spike.fw' in 'app' to state the identity the manifest declares.
   Resolving toolchain
    Resolved gcc@16.1.0 → @mcpp/registry/data/xpkgs/xim-x-gcc/16.1.0/bin/g++
      Target x86_64-linux-gnu → x86_64-unknown-linux-gnu
  build.mcpp compiling
  build.mcpp running
warning: app: SPIKE short=[]
    Inferred sources [src/**/*.{cppm,cpp,cc,c,S,s,asm}]
   Compiling app v0.1.0 (.)
   Compiling spike.fw v
    Finished dev [unoptimized + debuginfo] in 0.04s

The git source always yields the root manifest's package, and no key selects another package in the repository. (Separately, the compile line for the git dependency prints spike.fw v with an empty version.)

Need. A framework repository holds several packages: the framework, its rule packages, and tools such as the installer interface in E6.

  • Before they are published to an index, consumers pin the repository by git revision. HuxerUI's Live2D template and the Lib-Live2D library do so today.
  • With E6 fixed, such a consumer could name the tool only by path or by published version, not by the git pin the rest of its graph uses.

Proposed (for discussion), either or both:

  • By identity: when a git dependency's identity differs from the root manifest's, look for it among the root's [workspace] members. The warning above already knows both identities.
  • By path: a subdir = "tool" key on a git dependency. (Cargo resolves a git dependency by package name within the repository.)

CI check (ubuntu-24.04). A local repository created by the check with git init: spike.fw-installer = { git = "file://…", rev = "…", tools = [...] } builds the member's tool, with no identity warning.

E8. --features activates root features only, and without a [features] table accepts anything

Fixture. E6 without the cycle (the tool does not depend on fw), and the application without a [features] table.

Measured.

$ mcpp build --strict --features spike.fw/installer
…
warning: app: SPIKE short=[] qualified=[]
    Finished dev [unoptimized + debuginfo] in 0.04s
$ echo $?
0
$ mcpp build --strict --features nothing-here
…
    Finished dev [unoptimized + debuginfo] in 0.04s
$ echo $?
0

After adding [features] windows-installer = [] to the application:

$ mcpp build --features spike.fw/installer
warning: --features requests 'spike.fw/installer' which [features] does not declare
warning: app: SPIKE short=[] qualified=[]
$ mcpp build --strict --features spike.fw/installer
error: --features requests 'spike.fw/installer' which [features] does not declare
$ echo $?
2

mcpp build --help documents --features <LIST> as "Activate root-package features (comma-separated)", so ignoring a dependency's feature is as documented. Accepting any name, even under --strict, when the root has no [features] table is not.

Need. HuxerUI's CLI runs mcpp pack --format setup --features windows-installer for huxerui package windows, so each application declares that feature. With the tool provided by the framework (E6), each application would keep a windows-installer = ["huxerui.huxerui/windows-installer"] line only so the CLI can name it.

Proposed (for discussion).

  • Accept <dependency key>/<feature> for a direct dependency, applied exactly as a manifest forward.
  • Report a name that matches nothing whether or not the root has a [features] table.

CI check.

  • On the fixture, mcpp build --features spike.fw/installer builds the tool and dep_bin returns its path.
  • With no [features] table, mcpp build --strict --features nothing-here exits 2.

E9. A machine-readable report of what mcpp pack produced

Measured. mcpp pack reports its artifacts as human status lines. From HuxerUI's conformance runs:

      Packed target/.build-mcpp/out/conformance-0.1.0.AppImage
      Packed target/.build-mcpp/out/conformance.apk
  Packed leg aarch64-linux-android  [arm64-v8a]
      Packed target/.build-mcpp/out/conformance.app
      Packed target/.build-mcpp/out/huxerui-windows/conformance-Setup-0.1.0.exe
      Packed target/.build-mcpp/out/web/conformance.data
      Packed target/.build-mcpp/out/web/conformance.js
      Packed target/.build-mcpp/out/web/conformance.wasm
      Packed target/.build-mcpp/out/web/index.html
  • The path goes through mcpp::ui::shorten_path: project-relative, @mcpp/…, ~/… or absolute. A reader has to undo that.
  • A web pack prints one line per file, an iOS pack a directory, and a multi-row pack adds Packed leg lines.
  • HuxerUI's CLI publishes a pack's artifacts to dist/<platform>/, as its CMake backend does, by parsing these lines.
  • docs/50's protocol spells machine output --format json, but pack's --format already names the package format. mcpp test has --message-format json; mcpp pack --message-format json is error: unknown option: --message-format.
  • The stage manifest (docs/50) describes a staged tree, not the artifacts a format produced.

A smaller inconsistency:

$ mcpp build --release
    Finished release [optimized] in 0.00s
$ mcpp pack --release --format tar
error: unknown option: --release
$ mcpp pack --dev --format tar
error: unknown option: --dev

build and run accept --release / --dev. pack accepts only --profile (default [build] default-profile, else release). A driver mapping one profile switch onto the three verbs finds this on the pack.

Proposed (for discussion).

  • A report on stdout, for example mcpp pack --message-format json or a mcpp.pack kind in docs/50's envelope: one record per artifact with the absolute path, the format, the row or rows, and whether it is a file or a directory. Intermediate legs are separate records or left out.
  • Accept --release and --dev on pack, as on build and run.

CI check.

  • mcpp pack --format tar --message-format json (or the chosen spelling) prints a record whose path exists.
  • mcpp pack --release --format tar exits 0.

E10. The C/C++ runtime model of the llvm row on x86_64-windows-msvc (question)

Measured on windows-2022, in HuxerUI's conformance run. mcpp pack resolved llvm@20.1.7 → x86_64-windows-msvc, with no cxx_runtime or linkage key. The application program in the MSI, read with objdump -p:

  • mcpp (conformance.exe, 5 726 720 bytes) imports system DLLs (ADVAPI32.dll, USER32.dll, d3d11.dll, …) and api-ms-win-core-* API sets. It imports no msvcp140.dll, no vcruntime140.dll, no api-ms-win-crt-* and no ucrtbase.dll, so the C and C++ runtimes are linked into the program. The installer interface in the bundle is the same.
  • CMake with MSVC (conformance.exe, 1 925 632 bytes) imports MSVCP140.dll, MSVCP140_ATOMIC_WAIT.dll, VCRUNTIME140.dll, VCRUNTIME140_1.dll and api-ms-win-crt-*, and its MSI installs those four DLLs beside it.

The size difference was not examined.

What the docs and the source say.

  • docs/20-toolchains.md describes the CRT model ("/MD (host-coupled) by default; /MT when either … is written down") under "Native cl.exe builds". "On the MSVC runtime" maps cxx_runtime values to /MT and /MD.
  • src/build/flags.cppm adds the CRT flag only when isMsvcDialect = (d.id == "msvc"), and the std module build notes that "Non-MSVC dialects yield "" and the command is unchanged". The llvm row therefore gets clang's default, which the imports above show is the static runtime.

Questions.

  1. Is the static runtime the intended default for the llvm row on x86_64-windows-msvc, which docs/20 names as the default toolchain for the MSVC ABI? If so, it would help to state it for that row.
  2. Can cxx_runtime = "host-coupled" or "toolchain-coupled" select the dynamic runtime there (for example through -fms-runtime-lib=dll)? Reading the source, the key does not reach this row; that was not measured.
  3. Three findings left open by #641/#642: static dependencies of a shared package (F1), the payload's Mach-O private libc++ (F2), the std module initialiser in every image (F3) #646 F2 notes that the PE default for shared libraries (a private runtime per DLL) has not been examined. The answer to question 1 decides whether this row's DLLs are in that case.

HuxerUI's specification allows this difference for now: CMake installs the MSVC runtime DLLs, and mcpp links the runtime in.

mcpp:plugins follow-ups (low priority)

  • P1, dist-apple: a defaulted Info.plist key can change its value but cannot be removed.
    • On macOS, NSHighResolutionCapable is always written; on iOS, LSRequiresIPhoneOS (dist/apple.cppm, the replaced(...) checks).
    • CMake's Info.plist templates state neither, so HuxerUI's specification lists both as allowed differences.
    • Proposed: a removal marker for a key in the project's Info.plist entries, or an option listing defaulted keys to omit.
  • P2, dist-web: the page is always index.html.
    • CMake builds of Emscripten name the page after the target.
    • Proposed: an option naming the page file, with index.html as the default.

Already released in 0.11.1 (mcpp-community/mcpp-plugins#27):

  • dist-apk strips native libraries (the workaround E5 would make unnecessary);
  • dist-apk stores lib/ uncompressed on 16 KB pages when the manifest states android:extractNativeLibs="false";
  • dist-appimage takes SVG icons.

Still open elsewhere

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingenhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions