Skip to content

fix(pack): the build machine does not travel, and packages ship stripped (#460) - #464

Merged
Sunrisepeak merged 8 commits into
mainfrom
feat/pack-relocate-and-strip
Aug 20, 2026
Merged

fix(pack): the build machine does not travel, and packages ship stripped (#460)#464
Sunrisepeak merged 8 commits into
mainfrom
feat/pack-relocate-and-strip

Conversation

@Sunrisepeak

@Sunrisepeak Sunrisepeak commented Aug 19, 2026

Copy link
Copy Markdown
Member

Closes #460.

What was wrong

mcpp pack of a kind = "shared" target copied the linked .so into the package and did nothing else, so it kept the DT_RUNPATH the link gave it — a list of absolute paths into the build machine's ~/.mcpp/ store. On any other machine the consumer dies with libstdc++.so.6: cannot open shared object file.

Why the issue's own suggested fix does not work

The report asks for "no RUNPATH (or $ORIGIN)". Measured on a real mcpp pack product consumed by a real mcpp-built program, with the build machine's store made unreachable:

state on the shipped .so consumer's DT_RPATH inherited? result
stale absolute DT_RUNPATH (the old behaviour) no rc=127
no tag at all yes ok
DT_RUNPATH = $ORIGIN no rc=127
DT_RUNPATH = "" (what --set-rpath '' writes) no rc=127
stale DT_RPATH (--force-rpath) yes ok, but violates the loader contract

An object carrying any DT_RUNPATH makes the loader skip the entire inherited DT_RPATH chain when resolving that object's own dependencies. So the criterion is "there is no tag", not "the tag is relative" — and removing it is the right answer rather than a compromise: the consumer's own DT_RPATH is the same closure (payload, package dir, SubOS farm) resolved on the machine that will actually run it.

Three defects, one code path

  1. mcpp pack 对 kind = "shared" 的库没有剥离构建机的 RUNPATH,产物不可移植 #460 itself. New mcpp.pack.relocate removes the entry by editing PT_DYNAMIC in process — delete the slot, shift the tail, pad with DT_NULL; same file length, no offset fixups. Not patchelf: library packs are cross-target by construction and have no host gate, so sandbox_patchelf resolves to nothing on a macOS or Windows host, and the application packer's shape for that case is if (!patchelf.empty()) — silently do nothing. ELF32 and big-endian are covered by unit tests (no CI job produces one; --target can). Mach-O LC_RPATH is read and reported, not yet rewritten.

  2. mcpp pack <program> on a Mach-O artifact RAN THE USER'S PROGRAM. The closure step asks the dynamic linker via LD_TRACE_LOADED_OBJECTS=1 '<binary>'; that variable is glibc's, dyld ignores it, so the command just executes the program — and its stdout is then parsed as a dependency table, yielding a bundle reported as Packed. Now refused, keyed on the format (not the host), matching the _WIN32 refusal beside it. Never noticed because the e2e harness grants the pack capability only where elf + patchelf exist, i.e. Linux.

  3. The SONAME alias' copy fallback read leg.artifact, not the staged file. Byte-identical while nothing modified the staging copy; with relocate and strip in place it would ship an unprocessed library under the exact name the loader asks for — on the machines where create_symlink fails.

Packaging now builds release and strips what it ships

Only the profile fallback changes (devrelease). --profile and [build] default-profile still win, so mcpp pack never produces flags mcpp build would not.

Stripping follows dh_strip's division, and the archive row is measured:

artifact flags why not more
executable --strip-all nothing links against it
shared library --strip-unneeded keeps .dynsym — that IS the export list
static archive --strip-debug --enable-deterministic-archives --strip-all removes the archive symbol index → the consumer's link fails with archive has no index; run ranlib to add one

Bundled third-party .so files are not stripped — mcpp did not build them. New --profile / --no-strip / --debug-symbols DIR, and [pack] strip / [pack] debug_symbols. --debug-symbols separates rather than discards and adds a .gnu_debuglink.

[pack] strip is deliberately not [profile.<n>].strip: the profile key appends -s to the link, which never touches a static archive and cannot separate anything.

Guards

  • e2e 264 puts the defect back with patchelf and requires the consumer to fail before restoring it. The pre-existing 251 consumed the package on the machine that built it, which is why it stayed green throughout this bug's life — a guard that cannot observe the defect is not a guard.
  • e2e 265 pins all three switches from both sides, and consumes both a stripped static archive and a stripped shared library rather than inspecting them.
  • e2e 266 (macOS) pins the Mach-O refusal and that a library target still packs on the same host — asserting only the refusal cannot tell "the gate works" from "pack is broken here".
  • 12 unit tests for the ELF editor (ELF32/64 × LE/BE, Both, already-clean, archive, missing file) and the strip table.
  • tests/e2e/_elf_tag.sh: 215 and 264 now share one ELF reader.

⚠️ The guard reads the dynamic entries, never the file's bytes. Removing the entry leaves the path string in .dynstr (patchelf --remove-rpath leaves the identical residue at the identical size; .dynstr is tail-merged so deleting it cannot be shown safe). A grep-style criterion would report a correctly relocated artifact as dirty — and would also have flipped to green for an unrelated reason the day stripping landed.

Found while reviewing this diff (later commits on the branch)

Three more silent-wrong-answers, each of which would have shipped:

  • Whether stripping applies is a property of the TARGET, not the compiler. Keying it on tc.compiler != MSVC is wrong in both directions: clang → x86_64-windows-msvc produces .pdb debug info and would have been asked to strip in-band DWARF that is not there; and Apple's clang ships no llvm-strip, so the rule would have refused every mcpp pack on macOS — for a format whose linked image carries a debug map (N_OSO stanzas naming the .o files) and leaves the DWARF outside it. debug_info_is_in_band(canonicalTriple) answers it segment-wise.

  • A fat package's --debug-symbols files collided. Its legs share an artifact name (libmathkit-shared.so for both a gnu and a musl leg is the documented normal case), so a flat debug directory had the second leg overwrite the first, and the first artifact's .gnu_debuglink then resolved to the other target's symbols. They now mirror lib/<triple>/, and 265 asserts the layout rather than just the file's existence.

  • The empty-bundle relocate was gated on patchelf — the exact shape the in-process editor exists to remove. mcpp pack --mode system on a host whose sandbox has no patchelf left the build machine's store in the artifact and said nothing.

Locally verified on every target family this machine can reach

result
ELF/glibc shared library relocated (tag gone), stripped 16680 → 13952, consumer runs; defect restored → rc=127; restored → runs
ELF/glibc static archive stripped 1394 → 1258, archive index intact, consumer links and runs
ELF/musl --mode static statically linked, stripped, runs
PE/MinGW cross from Linux stripped 2 933 883 → 1 309 696
examples/05-lib-distribution packed with headers + interface, secrets withheld, the example's own consumer runs
profile precedence default 16680 (release) / --profile dev 17968 / [build] default-profile = "dev" 17968

Verified locally

  • 92 unit tests pass (12 new).
  • 26 pack / shared-library e2e tests pass, including the three new ones.
  • Profile precedence checked by artifact size: default 16680 (release), --profile dev 17968, [build] default-profile = "dev" 17968.

Design records: .agents/docs/2026-08-20-issue460-shared-library-runpath.md, .agents/docs/2026-08-20-pack-and-consumer-model-review.md.

…ped (#460)

A `kind = "shared"` package kept the DT_RUNPATH the link gave it — a list of
absolute paths into the BUILD MACHINE's store — so on any other machine the
consumer died with `libstdc++.so.6: cannot open shared object file`.

The issue's suggested fix does not work, and that is the whole design. Measured
on a real package with the build machine's store made unreachable:

  stale absolute DT_RUNPATH   consumer's DT_RPATH inherited? no    rc=127
  no tag at all                                              YES   ok
  DT_RUNPATH = $ORIGIN                                       no    rc=127
  DT_RUNPATH = "" (what --set-rpath '' writes)               no    rc=127

An object carrying ANY DT_RUNPATH makes the loader skip the whole inherited
DT_RPATH chain for that object's dependencies. So the criterion is "there is no
tag", and removing it is the right answer rather than a compromise: the
consumer's own DT_RPATH is the same closure — payload, package dir, SubOS farm —
resolved on the machine that will actually run it.

New `mcpp.pack.relocate` edits PT_DYNAMIC in process (delete the slot, shift the
tail, pad with DT_NULL; same file length) instead of shelling out to patchelf.
Library packs are cross-target by construction and have no host gate, so
`sandbox_patchelf` resolves to nothing on a macOS or Windows host — and the
application packer's shape for that is `if (!patchelf.empty())`, i.e. silently
do nothing. ELF32 and big-endian are covered by unit tests; no CI job produces
one and `--target` can. Mach-O LC_RPATH is read and reported, not yet rewritten.

Also refuses `mcpp pack <program>` for a Mach-O artifact. That path resolves the
dependency closure with `LD_TRACE_LOADED_OBJECTS=1 '<binary>'`, which is glibc's
variable — dyld ignores it and RUNS THE PROGRAM, then parses its stdout as a
dependency table and reports `Packed`. Keyed on the format, not the host, like
the `_WIN32` refusal beside it. Never noticed because the e2e harness grants the
`pack` capability only where elf+patchelf exist, i.e. Linux.

And the third silent one: the SONAME alias' copy fallback read `leg.artifact`
rather than the staged file. Byte-identical while nothing modified the staging
copy; with relocate and strip in place it would ship an unprocessed library
under the exact name the loader asks for, on the machines where create_symlink
fails.

Packaging now builds release and strips what it ships. Only the profile FALLBACK
changes (dev -> release); `--profile` and `[build] default-profile` still win, so
pack never produces flags `mcpp build` would not. Stripping follows dh_strip's
division, and the archive row is measured: `--strip-all` on a `.a` removes the
archive symbol index and the consumer's link fails with `archive has no index;
run ranlib to add one`, while `--strip-debug` links and runs. Shared libraries
get `--strip-unneeded` (keeps .dynsym), executables `--strip-all`, and bundled
third-party .so files nothing at all. New `--profile` / `--no-strip` /
`--debug-symbols DIR` and `[pack] strip` / `[pack] debug_symbols`;
`--debug-symbols` separates rather than discards and adds a .gnu_debuglink.

e2e 264 puts the defect BACK with patchelf and requires the consumer to FAIL
before restoring it: 251 consumed the package on the machine that built it, so
it was green throughout this bug's life. The guard reads the DYNAMIC ENTRIES,
never the file's bytes — the dead string stays in .dynstr (patchelf leaves the
identical residue; .dynstr is tail-merged and deleting it cannot be shown safe),
and a byte-pattern check would also have flipped to green for an unrelated
reason the day stripping landed.
… the compiler

Keying `inBandDebugInfo` on `tc.compiler != MSVC` is wrong in both
directions, and each direction is a configuration mcpp ships:

  clang -> x86_64-windows-msvc  produces .pdb debug info, and would have been
                                asked to strip in-band DWARF that is not there.
  Apple clang on macOS          ships no llvm-strip, so the rule would REFUSE
                                every `mcpp pack` on a Mac — for a format whose
                                linked image carries a debug MAP (N_OSO stanzas
                                naming the .o files) and leaves the DWARF
                                outside it. `objcopy --only-keep-debug` there
                                has nothing to copy, and .dSYM is dsymutil's job.

`debug_info_is_in_band(canonicalTriple)` answers it segment-wise from the
canonical triple, which both packers have already resolved. ELF and PE/MinGW
are in-band; Mach-O and the MSVC ABI are not.
check_docs_style.sh enforces bilingual heading parity, and the zh section had
been anchored on the wrong neighbour: it landed after 配置项 as an h3 where the
English one is an h4 immediately following the Windows cross-packing note. Same
place, same level — the two files are read side by side.
…-bundle relocate needs no patchelf

Two defects found reviewing my own diff.

A fat package's legs SHARE an artifact name — `libmathkit-shared.so` for both
the gnu and the musl leg is the documented normal case, not a corner one — so a
flat `--debug-symbols` directory had the second leg overwrite the first, and the
first artifact's .gnu_debuglink then resolved to the other target's symbols.
Silently. The debug files now mirror `lib/<triple>/`, and 265 asserts the layout
rather than just the file's existence.

And the empty-bundle case sat inside `if (!patchelf.empty())`, which is the exact
shape the in-process editor exists to remove: `mcpp pack --mode system` on a host
whose sandbox has no patchelf left the build machine's store in the artifact and
said nothing at all.
…e a bare build does

The PE cross-pack test drops a stand-in msvcrt.dll into the build tree and then
expects `mcpp pack` to find it in the closure. With packaging defaulting to
release, the two commands resolve to different profiles and therefore different
target/<triple>/<fingerprint>/ directories — the file lands in the one pack does
not use, and the assertion reads as 'the closure reader failed' when nothing
about the closure is wrong.

The fixture now states `[build] default-profile`, which settles it for both and
doubles as a check that the manifest still outranks pack's fallback. The
user-visible half of the same fact is now in docs/02 and the changelog: a file
placed beside a built artifact by hand is only visible to pack when both
commands resolve to the same profile. The declarative channels are unaffected.
Measured on a macOS ARM64 runner, in a step that had not yet executed a line of
mcpp:

  fatal: unable to access 'https://github.com/openxlings/xlings/':
         Could not resolve host: github.com

Thirty seconds of resolver timeout, then a failed PR check that says nothing
about the change under test. Every workflow that reaches an external repository
did it with a bare `git clone` — six call sites, one failure mode — while this
repository had already learned the same lesson one protocol over: fetch_release.sh
carries a long note on `curl --retry` not covering transport-layer errors and
`--retry-all-errors` being the flag that does. git has no such flag.

.github/tools/git_clone_retry.sh retries EVERY failure, bounded. It does not
try to tell a DNS blip from a missing repository: git reports both as exit 128
with only the message to distinguish them, and parsing that message would be a
weaker copy of git's own taxonomy that breaks the first time git rephrases one.
A genuinely missing repo costs the attempts and then fails with git's own last
message intact (measured: 4s, exit 128); a resolver blip costs one backoff.

⚠️ The helper's first draft exited 0 on a clone that never succeeded — `$?`
after an `if` is the status of the IF STATEMENT, which is 0 when the body did
not run. That turns a hard failure into a green step with a missing checkout.
Caught by asserting the exit code in its own test rather than by reading the
output, which looked correct.

ci-aarch64-fresh-install spells the retry inline instead: it checks the
repository out LAST ON PURPOSE (a .xlings.json in the workspace re-points where
`xlings install` writes, so an early checkout silently changes what the
fresh-install steps are testing), so the shared helper does not exist on disk at
those two clones. Same policy, and the comment says why it is not the same file.

Verified: real clone exits 0 with content; bad repo exits 128 bounded;
unreachable host retries then fails with git's message; a partial destination is
cleared before the retry; the inline form survives `set -e`.
00_fixture_path_hygiene refuses an inline `$(host_path …)` in a manifest
heredoc, and it is right to: the manifest is FILE CONTENT, and on Git Bash a
shell-spelled /tmp/... path is read by a native mcpp.exe as 'root of the current
drive'. Naming the converted value is what makes the conversion visible where it
is used rather than buried in an interpolation.
… names the artifact

Both files are about ROUTING — a workspace root hands through to its member's
program; `mcpp pack <name>` reaches the target it was given — and both proved
it by looking inside a produced bundle. On macOS that bundle can no longer be
produced, so the macOS e2e job went red on two tests that are not about
bundling at all.

Worth noting what their previous green meant: they passed on macOS while
`mcpp pack` was resolving the dependency closure by RUNNING the user's program.
The bundle they inspected was the one that produced.

The Mach-O refusal now names the artifact it reached. That is not decoration:
a refusal that does not say WHICH program it got to is indistinguishable from
one that resolved the wrong target — the exact defect route_pack_target exists
to prevent — and it is the only evidence available on a platform where no
bundle can be inspected. 250 additionally keeps the unknown-name case, which
must still fail EARLIER and differently, or 'refuses everything' would pass.

Linux paths unchanged and re-verified; the new string is present in the rebuilt
binary.
@Sunrisepeak
Sunrisepeak merged commit 6b5084b into main Aug 20, 2026
21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

mcpp pack 对 kind = "shared" 的库没有剥离构建机的 RUNPATH,产物不可移植

2 participants