Skip to content

Commit 3e781ca

Browse files
committed
feat(toolchain): iOS is an ecosystem compiler and a located SDK
D1/D2. THE THREE iOS ROWS PIN `llvm@22.1.8`. The sentence that shrank this item is that the compiler is ours and only the SDK is Apple's: any sufficiently new clang emits arm64 Mach-O for an iOS deployment target, and `aarch64-macos` is verified on exactly that split. Xcode's clang would also work and is the wrong default, because it would make the compiler a host dependency where the ecosystem already has one. A CONVENTION PIN, NOT A CAPABILITY ONE. It answers "what does `--target aarch64-ios` resolve when the project says nothing" and stays overridable -- consistent with `aarch64-macos`, where the same constraint (Darwin needs clang) holds and which is not a capability row either. The rows carry no `sysroot`, because that column names a PACKAGE and an Apple SDK is located. The deployment target travels in the EFFECTIVE TRIPLE and nowhere else: `arm64-apple-ios18.0` and `arm64-apple-ios18.0-simulator`, Apple's own spellings, which fully determine platform and minimum. `-miphoneos-version-min` is deliberately not emitted -- it would be a second place answering a question the triple already answers. macOS keeps its flag, and the asymmetry now has a criterion of its own in the triple tests rather than being folded into this. FOUR SITES HAD TO LEARN THAT A macOS HOST IS NOT A macOS TARGET. - hostflags emitted `-mmacosx-version-min` whenever the HOST was macOS. With an iOS triple clang refuses the combination outright, so the flag would stop the build rather than be ignored. Gated on the located SDK being empty, which is the same value that says "this is a cross". - the compile side gets `-isysroot <located SDK>`, which it never needed before: a native build reads the payload's `clang++.cfg`, and post-install filled that with the located macOS SDK. An Apple cross suppresses the cfg precisely because it names the wrong platform. - the LINK line in that branch carried no `--target` at all. The note two hundred lines below it records that only the third branch ever consumed `link_toolchain_flags`; an iOS row is the first target this branch serves that the driver would get wrong, and a link told nothing produces a macOS binary from objects compiled as iOS. - the Mach-O distribution cell chose the self-contained contract, which links the payload's `libc++.a` -- a Mach-O archive built for macOS, which ld64 refuses in an iOS link. iOS takes libc++ from the SDK, which every iOS release ships, and the floor stays real because the triple carries it. `ios_deployment_target` in `[build]`, beside `macos_deployment_target`: one fingerprint slot, two version spaces, because "14.0" is a macOS version and means nothing to an iOS SDK. THE SDK IS LOCATED ONCE AND ITS ABSENCE IS A REFUSAL THAT NAMES IT. Three later sites need the answer and `xcrun` shells out, so probing three times could return three answers. The refusal names the SDK, the `xcrun` command that must answer, that Command Line Tools alone ship the macOS SDK only, and that the compiler is not the problem. AND THE PROBE TAUGHT THE MEASUREMENT ITS OWN LESSON. The first ci-macos-ios.yml run answered the premise -- Xcode 16.4, iPhoneOS 18.5, iPhoneSimulator 18.5, five iOS simulator runtimes, an iPhone 16 Pro that boots -- and then failed with ld64.lld: error: .../MacOSX.sdk/usr/lib/libc++.tbd(...) is incompatible with arm64 (iOS Simulator18.0.0) with the correct `-isysroot` on the command line: the payload's cfg won. That is the fact behind three of the four sites above. The four steps after the failing one were SKIPPED, so one unmet assumption cost the rest of the measurement; every step is now `continue-on-error`, because a probe that stops at the first `no` is a build and not a measurement. A second job builds mcpp from this branch and asks the engine the same questions: the device row's artefact and its `LC_BUILD_VERSION`, the simulator row plus `simctl spawn` on the bare Mach-O -- which decides whether `simctl-run` is a boot-and-spawn wrapper or has to synthesise a bundle -- and that the refusal names the SDK while the host row still builds in the same environment. The rows stay `planned` until those answers arrive; the fixture uses the escape hatch the refusal itself advertises.
1 parent 200d5fe commit 3e781ca

8 files changed

Lines changed: 457 additions & 22 deletions

File tree

.github/workflows/ci-macos-ios.yml

Lines changed: 171 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,14 @@ jobs:
3939
# the device row is still verifiable here and the two simulator rows
4040
# become a local-only claim -- which changes the tier they can reach and
4141
# nothing else. This step is the difference between those two worlds.
42+
# EVERY MEASUREMENT STEP CONTINUES, because the first version of this job
43+
# did not and the four steps after the failing one were SKIPPED -- so one
44+
# unmet assumption cost the whole measurement. A probe's value is the
45+
# complete set of answers; a probe that stops at the first `no` is a
46+
# build, not a measurement. (GitHub runs `run:` under `bash -e`, which is
47+
# what turned a failing clang into a terminated step.)
4248
- name: "Host surface: the two located SDKs and the simulator runtime"
49+
continue-on-error: true
4350
run: |
4451
set -x
4552
xcode-select -p
@@ -59,7 +66,30 @@ jobs:
5966
# `simctl-run` program is a boot-and-spawn wrapper; if it does not, it
6067
# has to synthesise a bundle, sign it and install it. Measuring this is
6168
# cheaper than designing for the harder case.
69+
# THE PAYLOAD'S CLANG READS A CONFIG FILE, AND THAT CONFIG NAMES A
70+
# DIFFERENT SDK. First run, with `-isysroot <iPhoneSimulator18.5.sdk>`
71+
# on the command line:
72+
#
73+
# ld64.lld: error: /Library/Developer/CommandLineTools/SDKs/
74+
# MacOSX.sdk/usr/lib/libc++.tbd(/usr/lib/libc++.1.dylib) is
75+
# incompatible with arm64 (iOS Simulator18.0.0)
76+
#
77+
# mcpp's own post-install writes the located macOS SDK into the
78+
# payload's `clang++.cfg` so that a native macOS build is deterministic,
79+
# and that cfg is read before the command line for search purposes. So
80+
# an Apple CROSS target has to suppress it -- which mcpp's cross path
81+
# already does (`--no-default-config` in hostflags.cppm), and which this
82+
# hand-written probe did not. Printed here because the explanation
83+
# belongs next to the measurement.
84+
- name: "The payload's default config, which is why --no-default-config"
85+
continue-on-error: true
86+
run: |
87+
set -x
88+
ls -la "$LLVM_ROOT/bin/"*.cfg || true
89+
for f in "$LLVM_ROOT/bin/"*.cfg; do echo "=== $f"; cat "$f"; done || true
90+
6291
- name: "Device: is one bootable, and does spawn take a bare executable"
92+
continue-on-error: true
6393
run: |
6494
set -uo pipefail
6595
UDID=$(xcrun simctl list devices available \
@@ -71,15 +101,18 @@ jobs:
71101
exit 0
72102
fi
73103
xcrun simctl boot "$UDID" || true
74-
xcrun simctl bootstatus "$UDID" -b || true
104+
xcrun simctl bootstatus "$UDID" -b 2>&1 | tail -3 || true
75105
cat > /tmp/hello.cpp << 'CPP'
76106
#include <cstdio>
77107
int main() { std::puts("1-2-3"); return 0; }
78108
CPP
79109
SDK=$(xcrun --sdk iphonesimulator --show-sdk-path)
80-
"$LLVM_ROOT/bin/clang++" -std=c++23 \
81-
-target arm64-apple-ios18.0-simulator \
82-
-isysroot "$SDK" -o /tmp/hello /tmp/hello.cpp
110+
if ! "$LLVM_ROOT/bin/clang++" -std=c++23 --no-default-config \
111+
-target arm64-apple-ios18.0-simulator \
112+
-isysroot "$SDK" -o /tmp/hello /tmp/hello.cpp; then
113+
echo "SIM-COMPILE-FAILED"
114+
exit 0
115+
fi
83116
file /tmp/hello
84117
otool -l /tmp/hello | grep -A5 LC_BUILD_VERSION || true
85118
echo "--- simctl spawn on a bare Mach-O ---"
@@ -95,14 +128,16 @@ jobs:
95128
# that says MACOS there is the failure this leg exists to catch, and it
96129
# is invisible to a build that merely succeeds.
97130
- name: "Device: the artefact names the iOS platform"
131+
continue-on-error: true
98132
run: |
99133
set -euo pipefail
100134
cat > /tmp/dev.cpp << 'CPP'
101135
#include <cstdio>
102136
int main() { std::puts("1-2-3"); return 0; }
103137
CPP
104138
SDK=$(xcrun --sdk iphoneos --show-sdk-path)
105-
"$LLVM_ROOT/bin/clang++" -std=c++23 -target arm64-apple-ios18.0 \
139+
"$LLVM_ROOT/bin/clang++" -std=c++23 --no-default-config \
140+
-target arm64-apple-ios18.0 \
106141
-isysroot "$SDK" -o /tmp/dev /tmp/dev.cpp
107142
file /tmp/dev
108143
otool -l /tmp/dev | grep -A5 LC_BUILD_VERSION
@@ -115,6 +150,7 @@ jobs:
115150
# works" is the fact the flag builder needs and neither answer can be
116151
# reasoned out from a Linux desk.
117152
- name: "C++ runtime: SDK libc++ versus the payload static archive"
153+
continue-on-error: true
118154
run: |
119155
set -x
120156
SDK=$(xcrun --sdk iphoneos --show-sdk-path)
@@ -124,15 +160,17 @@ jobs:
124160
int main() { std::string s = "1-2-3"; std::puts(s.c_str()); return 0; }
125161
CPP
126162
echo "--- (a) SDK libc++, dynamic ---"
127-
if "$LLVM_ROOT/bin/clang++" -std=c++23 -target arm64-apple-ios18.0 \
163+
if "$LLVM_ROOT/bin/clang++" -std=c++23 --no-default-config \
164+
-target arm64-apple-ios18.0 \
128165
-isysroot "$SDK" -o /tmp/cxx-sdk /tmp/cxx.cpp; then
129166
otool -L /tmp/cxx-sdk
130167
echo "SDK-LIBCXX-OK"
131168
else
132169
echo "SDK-LIBCXX-FAILED"
133170
fi
134171
echo "--- (b) payload static libc++ ---"
135-
if "$LLVM_ROOT/bin/clang++" -std=c++23 -target arm64-apple-ios18.0 \
172+
if "$LLVM_ROOT/bin/clang++" -std=c++23 --no-default-config \
173+
-target arm64-apple-ios18.0 \
136174
-isysroot "$SDK" -nostdlib++ \
137175
"$LLVM_ROOT/lib/libc++.a" "$LLVM_ROOT/lib/libc++abi.a" \
138176
-o /tmp/cxx-static /tmp/cxx.cpp; then
@@ -148,13 +186,15 @@ jobs:
148186
# uses, with a second SDK. If this cannot be made to work the iOS rows
149187
# are a non-module tier and the documentation has to say so.
150188
- name: "import std: precompile against the located SDK"
189+
continue-on-error: true
151190
run: |
152191
set -x
153192
SDK=$(xcrun --sdk iphoneos --show-sdk-path)
154193
STD_CPPM=$(find "$LLVM_ROOT" -name 'std.cppm' | head -1)
155194
echo "std.cppm=$STD_CPPM"
156195
MODDIR=$(dirname "$STD_CPPM")
157-
if "$LLVM_ROOT/bin/clang++" -std=c++23 -target arm64-apple-ios18.0 \
196+
if "$LLVM_ROOT/bin/clang++" -std=c++23 --no-default-config \
197+
-target arm64-apple-ios18.0 \
158198
-isysroot "$SDK" -Wno-reserved-module-identifier \
159199
-Xclang -emit-reduced-module-interface \
160200
--precompile -o /tmp/std.pcm "$STD_CPPM" -I"$MODDIR"; then
@@ -166,7 +206,8 @@ jobs:
166206
import std;
167207
int main() { std::println("1-2-3"); return 0; }
168208
CPP
169-
if "$LLVM_ROOT/bin/clang++" -std=c++23 -target arm64-apple-ios18.0 \
209+
if "$LLVM_ROOT/bin/clang++" -std=c++23 --no-default-config \
210+
-target arm64-apple-ios18.0 \
170211
-isysroot "$SDK" -fmodule-file=std=/tmp/std.pcm \
171212
-o /tmp/mod /tmp/mod.cpp /tmp/std.pcm; then
172213
echo "MODULE-LINK-OK"
@@ -175,3 +216,124 @@ jobs:
175216
fi
176217
file /tmp/mod || true
177218
set +x
219+
220+
ios-engine:
221+
name: iOS - mcpp builds and the simulator runs it
222+
runs-on: macos-15
223+
timeout-minutes: 40
224+
steps:
225+
- uses: actions/checkout@v4
226+
with:
227+
submodules: recursive
228+
- uses: ./.github/actions/setup-macos-llvm
229+
230+
- name: Build mcpp from source (self-host)
231+
run: |
232+
export MCPP_VENDORED_XLINGS="$XLINGS_BIN"
233+
"$MCPP" build
234+
echo "MCPP_DEV=$(ls -td "$PWD"/target/*/*/bin/mcpp | head -1)" >> "$GITHUB_ENV"
235+
236+
# THE THREE ROWS ARE `planned`, AND THAT IS THE STATE THIS JOB EXISTS TO
237+
# CHANGE. A planned row is refused with its own escape hatch named:
238+
#
239+
# error: target 'aarch64-ios' is registered but not yet supported
240+
# (planned) -- An explicit [target.aarch64-ios] toolchain
241+
# override can opt in early.
242+
#
243+
# so the fixture writes that override. Once this job is green the tiers
244+
# move and the override becomes unnecessary; keeping it until then is
245+
# what makes the measurement possible before the claim is made.
246+
- name: "Fixture: a project that imports std and prints 1-2-3"
247+
run: |
248+
set -euo pipefail
249+
mkdir -p /tmp/iostest/src
250+
cat > /tmp/iostest/mcpp.toml << 'TOML'
251+
[package]
252+
name = "iostest"
253+
version = "0.1.0"
254+
255+
[build]
256+
ios_deployment_target = "18.0"
257+
258+
[target.aarch64-ios]
259+
toolchain = "llvm@22.1.8"
260+
261+
[target.aarch64-ios-sim]
262+
toolchain = "llvm@22.1.8"
263+
264+
[target.x86_64-ios-sim]
265+
toolchain = "llvm@22.1.8"
266+
TOML
267+
cat > /tmp/iostest/src/main.cpp << 'CPP'
268+
import std;
269+
int main() {
270+
std::vector<int> v{3, 1, 2};
271+
std::ranges::sort(v);
272+
std::print("{}-{}-{}\n", v[0], v[1], v[2]);
273+
}
274+
CPP
275+
cat /tmp/iostest/mcpp.toml
276+
277+
# D1/D2: THE DEVICE ROW'S ARTEFACT. Nothing runs it -- that needs a
278+
# signature the developer owns -- so the claim is about the artefact:
279+
# arm64 Mach-O naming the iOS platform and the project's deployment
280+
# target in LC_BUILD_VERSION. A build that merely succeeds cannot tell
281+
# those apart from a macOS binary.
282+
- name: "D1: mcpp build --target aarch64-ios"
283+
continue-on-error: true
284+
run: |
285+
set -x
286+
cd /tmp/iostest
287+
"$MCPP_DEV" build --target aarch64-ios
288+
set +x
289+
ART=$(find /tmp/iostest/target/aarch64-ios -name iostest -type f | head -1)
290+
echo "artefact=$ART"
291+
file "$ART"
292+
otool -l "$ART" | grep -A5 LC_BUILD_VERSION
293+
294+
# D4's PREMISE: whether `simctl spawn` takes a bare Mach-O. If it does,
295+
# the `simctl-run` program in xim:apple-simulator-tools is a
296+
# boot-and-spawn wrapper; if it does not, it has to synthesise a bundle,
297+
# sign it and install it. This is the measurement that decides which.
298+
- name: "D4: mcpp build --target aarch64-ios-sim, then simctl spawn"
299+
continue-on-error: true
300+
run: |
301+
set -x
302+
cd /tmp/iostest
303+
"$MCPP_DEV" build --target aarch64-ios-sim
304+
set +x
305+
ART=$(find /tmp/iostest/target/aarch64-ios-sim -name iostest -type f | head -1)
306+
echo "artefact=$ART"
307+
file "$ART"
308+
otool -l "$ART" | grep -A5 LC_BUILD_VERSION
309+
UDID=$(xcrun simctl list devices available \
310+
| grep -A50 -- '-- iOS' \
311+
| grep -m1 -oE '[0-9A-F]{8}-[0-9A-F-]{27}' || true)
312+
echo "udid=[$UDID]"
313+
if [ -z "$UDID" ]; then echo "NO-IOS-SIMULATOR-DEVICE"; exit 0; fi
314+
xcrun simctl boot "$UDID" || true
315+
xcrun simctl bootstatus "$UDID" -b 2>&1 | tail -3 || true
316+
if xcrun simctl spawn "$UDID" "$ART"; then
317+
echo "SPAWN-OK"
318+
else
319+
echo "SPAWN-FAILED exit=$?"
320+
fi
321+
322+
# THE HOST SURFACE IS BOUNDED, AND ITS ABSENCE NAMES THE SDK. The
323+
# recorded rule is that a host dependency must be minimal, named, and
324+
# never a fallthrough -- so the refusal is a claim like any other, and
325+
# `SDKROOT` pointing at a macOS SDK is the cheapest way to make the iOS
326+
# SDK unlocatable without breaking the rest of the machine.
327+
#
328+
# AND NO OTHER ROW CHANGES: the same environment builds for the host.
329+
- name: "D: the refusal names the SDK, and no other row is affected"
330+
continue-on-error: true
331+
run: |
332+
set -x
333+
cd /tmp/iostest
334+
DEVDIR=/nonexistent-developer-dir
335+
env DEVELOPER_DIR="$DEVDIR" SDKROOT="" \
336+
"$MCPP_DEV" build --target aarch64-ios 2>&1 | tail -12
337+
set +x
338+
echo "--- and the host row still builds in the same environment ---"
339+
env DEVELOPER_DIR="$DEVDIR" "$MCPP_DEV" build 2>&1 | tail -4 || true

modules/toolchain-model/src/model.cppm

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,30 @@ struct Toolchain {
181181
// zero-libc tier and on hosted targets.
182182
std::string targetSysrootPkg;
183183
std::filesystem::path targetSysrootLib;
184+
// THE APPLE SDK THIS TARGET WAS LOCATED AGAINST, and it is a different
185+
// axis from the three paths above.
186+
//
187+
// `targetSysroot*` name a PACKAGE's directories -- a C library this
188+
// ecosystem ships and can therefore version. An Apple SDK is located, not
189+
// installed: the iPhoneOS and iPhoneSimulator headers and stub libraries
190+
// ship inside Xcode and are not redistributable, so there is nothing for
191+
// a package to name and the row carries no `sysroot` entry. What it
192+
// carries instead is this, resolved once where the target is known.
193+
//
194+
// Empty for every non-Apple target AND for a native macOS build, which
195+
// needs none: that build reads the payload's own `clang++.cfg`, and
196+
// `post_install.cppm` wrote the located macOS SDK into it. An Apple CROSS
197+
// target suppresses that cfg (`--no-default-config`) precisely because it
198+
// names the wrong platform -- measured 2026-09-11 on macos-15, where an
199+
// iOS-simulator link with the right `-isysroot` on the command line still
200+
// picked the cfg's SDK:
201+
//
202+
// ld64.lld: error: .../MacOSX.sdk/usr/lib/libc++.tbd(...) is
203+
// incompatible with arm64 (iOS Simulator18.0.0)
204+
//
205+
// so the SDK has to travel explicitly, on the compile side as well as the
206+
// link side.
207+
std::filesystem::path appleSdkRoot;
184208
std::vector<std::filesystem::path> compilerRuntimeDirs; // LD_LIBRARY_PATH for private tools
185209
std::vector<std::filesystem::path> linkRuntimeDirs; // -L/-rpath dirs for produced binaries
186210
// Environment the toolchain's tools need when invoked (set on the ninja

modules/toolchain-model/src/triple.cppm

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -712,6 +712,27 @@ inline constexpr TargetInfo kKnownTargets[] = {
712712
// linker and the Apple half of the toolchain model all exist; what is
713713
// missing is an `os` value and the iPhoneOS SDK.
714714
//
715+
// THE COMPILER IS OURS AND ONLY THE SDK IS APPLE'S, which is the sentence
716+
// that shrank this row from a packaging problem to a located directory.
717+
//
718+
// `llvm@22.1.8` -- any sufficiently new clang emits arm64 Mach-O for an
719+
// iOS deployment target, and the C++ runtime comes from the SDK the way it
720+
// does on every Apple platform. `aarch64-macos` is verified on exactly
721+
// this split and is the precedent: `xim:llvm` compiles and the SDK is
722+
// located. Xcode's own clang would also work and is the wrong default --
723+
// it would make the compiler a host dependency where the ecosystem
724+
// already has one, and these would be the only Apple rows not using
725+
// `xim:llvm`.
726+
//
727+
// A CONVENTION PIN, NOT A CAPABILITY ONE, which is why
728+
// `pin_is_capability()` does not name these rows. It means "this is what
729+
// `--target aarch64-ios` resolves when the project says nothing", and a
730+
// project may still write `[target.aarch64-ios] toolchain = "..."`. The
731+
// capability set is the rows NO other payload can serve, and the
732+
// distinction matters for consistency with `aarch64-macos`: the same
733+
// constraint (Darwin needs clang) holds there and that row is not a
734+
// capability row either.
735+
//
715736
// THE SDK IS A LICENCE QUESTION AND NOT A PACKAGING ONE, which is why this
716737
// row carries no `sysroot`. The NDK is Apache-2.0 and Emscripten is MIT,
717738
// both redistributable; the iPhoneOS SDK is neither. The recipe should
@@ -725,7 +746,7 @@ inline constexpr TargetInfo kKnownTargets[] = {
725746
// The simulator is deliberately not a row. It has its own SDK and produces
726747
// its own object, so folding it in would make two targets share an
727748
// identity -- the mistake `x86_64-windows-musl` was added to undo.
728-
{ "aarch64-ios", "planned", "", "", "", false },
749+
{ "aarch64-ios", "planned", "", "llvm@22.1.8","", false },
729750
// THE SIMULATOR'S TWO ROWS. Not a convenience and not a runner: a
730751
// simulator build has its own SDK (`iPhoneSimulator.sdk`), produces its own
731752
// object, and takes `-mios-simulator-version-min` rather than
@@ -743,8 +764,8 @@ inline constexpr TargetInfo kKnownTargets[] = {
743764
// redistributable than the iPhoneOS one. What these rows buy today is that
744765
// `mcpp build --target aarch64-ios-sim` answers `tier-planned` naming the
745766
// row, instead of `unknown target`, which was false.
746-
{ "aarch64-ios-sim", "planned", "", "", "", false },
747-
{ "x86_64-ios-sim", "planned", "", "", "", false },
767+
{ "aarch64-ios-sim", "planned", "", "llvm@22.1.8","", false },
768+
{ "x86_64-ios-sim", "planned", "", "llvm@22.1.8","", false },
748769

749770
// WEB IS THE OUTLIER, AND IT IS THE ONLY ONE OF THE THREE THAT CHANGES THE
750771
// MODEL RATHER THAN EXTENDING A TABLE. A new arch (`wasm32`), a new os

0 commit comments

Comments
 (0)