From f7e45845d0c0c6ac51e17fb11be2817b24aaaea5 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:29:53 +0800 Subject: [PATCH 01/14] docs(design): SDK toolchains, the payload/engine seam, and iOS local verification Four items, in dependency order: one measured and needing only to be written down, one deletion, one structural change, one new capability. A. `aarch64-linux-android` becomes `verified`. The earlier design's qemu-user route was correct and only its extraction tool was broken -- `debugfs` in `xim:e2fsprogs@1.47.3` is a broken build, and `xim:7zip` reads ext4 directly. Measured: 7zip extracts the bionic loader, qemu-aarch64-static runs the mcpp-built artifact, `1-2-3`, exit 0. B. The toolchain axis already behaves like every other: default from the row's pin, override through `[target.] toolchain`, version free, payload name fixed for a capability row. The change is to WITHDRAW the `ndk` alias, which `compat.cppm` accepts and the capability gate refuses. C. A payload describes itself through `.mcpp-toolchain.json`, written by the recipe that already knows the layout, so the engine stops carrying three NDK-specific facts. Absence is compatibility; a malformed descriptor is refused by name. D. iOS uses `xim:llvm` and a LOCATED SDK, which is the split `aarch64-macos` is already verified on. `macos.cppm` locates its SDK with `xcrun` today, so the change is a parameter rather than a concept, and the locator sits on the SYSROOT axis -- which is why `@system` stays withdrawn instead of generalised. The simulator is a `runner` from a package. The host surface added is two named items on one host, both category (b) of the recorded policy. --- ...k-toolchains-and-ios-local-verification.md | 311 ++++++++++++++++++ .agents/docs/README.md | 4 +- 2 files changed, 314 insertions(+), 1 deletion(-) create mode 100644 .agents/docs/2026-09-11-sdk-toolchains-and-ios-local-verification.md diff --git a/.agents/docs/2026-09-11-sdk-toolchains-and-ios-local-verification.md b/.agents/docs/2026-09-11-sdk-toolchains-and-ios-local-verification.md new file mode 100644 index 00000000..4f361f21 --- /dev/null +++ b/.agents/docs/2026-09-11-sdk-toolchains-and-ios-local-verification.md @@ -0,0 +1,311 @@ +--- +subject: targets +status: active +--- + +# SDK toolchains, the payload/engine seam, and iOS local verification + +**Status:** design, for review. Nothing here is implemented. + +**Scope.** Four items, in dependency order. The first is measured and only needs +writing down; the second removes something rather than adding; the third is the +only structural change; the fourth is the one with a new capability. + +| | item | shape | +|---|---|---| +| A | `aarch64-linux-android` becomes `verified`, and the extraction tool changes | evidence exists, two edits | +| B | the `ndk` toolchain alias is withdrawn | deletion | +| C | a payload describes itself, so the engine stops knowing the NDK | new contract at an existing seam | +| D | iOS builds and runs on the simulator, locally and in CI | one parameter, one row column, one runner package | + +## A. Android's device row has an execution path after all + +`aarch64-linux-android` was recorded `preview` on the grounds that no execution +path exists from an x86_64 host. That was wrong, and the reason it looked right +is worth stating: the route the earlier design named was correct, and only its +EXTRACTION TOOL was broken. + +Measured 2026-09-11: + +``` +7zz l Type = Ext ; bin/linker64 1120256 bytes +7zz x … 5 files extracted +qemu-aarch64-static -L + -> 1-2-3 exit 0 +``` + +`xim:e2fsprogs@1.47.3`'s `debugfs` SIGFPEs on every filesystem-opening command, +including against a control image made by that same payload's `mke2fs`, while +`dumpe2fs`, `e2fsck` and `tune2fs` from the same archive work. `xim:7zip` reads +ext4 directly and is already in the index. + +**Two consequences.** The row becomes `verified`, with the vehicle recorded: +the platform's own emulator for `x86_64-linux-android`, qemu-user plus the +system image's bionic for `aarch64-linux-android`. And +`pkgs/a/android-system-image.lua` takes its four files with `xim:7zip` instead +of `xim:e2fsprogs`, which makes the arm64 key installable for anyone rather +than only where a working host `debugfs` happens to exist. + +The e2fsprogs defect stays recorded where it is. It is not this change's to +fix, and nothing else in the index depends on that program. + +## B. The toolchain axis already has the semantics asked of it + +The question was whether an SDK toolchain behaves like any other: a default +that needs no declaration, and a per-target override in `mcpp.toml`. Measured, +it does: + +``` +no declaration -> Resolved emsdk@6.0.9 (auto-installed) +toolchain = "emsdk@6.0.9" -> Resolved emsdk@6.0.9 +toolchain = "emsdk@5.0.0" -> resolves, install fails: not in the index +toolchain = "android-ndk@30.0.16248370" -> Resolved android-ndk@… +toolchain = "llvm@22.1.8" -> refused: capability pin +``` + +So the default is the row's pin, the override is the ordinary +`[target.] toolchain` key, the **version** is free within what the +index publishes, and the **payload name** is fixed for a capability row. That +is the same shape every other toolchain has, and nothing needs to change. + +**What does change is a half-supported spelling.** `compat.cppm` accepts `ndk` +as an alias for `android-ndk`, and the capability gate compares the declared +spelling against the pin's own name -- so `toolchain = "ndk@30.0.16248370"` +parses and is then refused. A spelling the parser accepts and the gate rejects +is worse than one spelling: it reads as a defect at the point of use. + +**Withdraw the alias** rather than teach the gate to normalise. One payload, +one name. The gate's comparison is then correct by construction instead of +correct by a second mechanism, and there is one string in the ecosystem for +this payload -- the one the index uses. + +## C. The payload describes itself + +### The coupling, named + +Three NDK-specific facts live in the general engine today: + +| fact | site | what it encodes | +|---|---|---| +| `toolchains/llvm/prebuilt//bin` | `registry.cppm` `frontendSubdir` + `ndk_host_tag()` | the NDK's internal directory layout, per host | +| the API floor | `registry.cppm` `ndk_min_api_level()` | that the floor lives in `meta/platforms.json`, and that file's schema | +| `-D__BIONIC_CTYPE_INLINE=` | `prepare.cppm` | that this libc++ module surface needs it against this bionic | + +Each is a fact the **installing recipe already knows**: `pkgs/a/android-ndk.lua` +computes `host_tag()` for its own probes, reads the payload to check its libc++ +version, and applies the same define in its own self-test. The engine +re-derives all three, which is why adding a second such SDK means editing the +engine rather than publishing a package. + +### The contract + +The recipe writes one file beside the payload at install time; the engine reads +it if present. + +``` +/.mcpp-toolchain.json +{ + "schema": 1, + "frontend": "toolchains/llvm/prebuilt/linux-x86_64/bin/clang++", + "platform_floor": "21", + "std_module_defines": ["__BIONIC_CTYPE_INLINE="] +} +``` + +- **`frontend`** is a path relative to the payload root, already host-resolved + by the recipe. The engine stops computing a host tag, and `frontendSubdir` + becomes the fallback for a payload that ships no descriptor. +- **`platform_floor`** is the string `llvm_triple(param)` already takes. It is + the payload's answer, not a constant compiled in -- which is the property + `ndk_min_api_level()` was written to get and this generalises. +- **`std_module_defines`** reach the std module's own command assembly, which + is a separate channel from the compile flags. They enter the build + fingerprint, because they change what the module compiles to. + +### Three properties this has to have + +**Absence is compatibility, not silence.** No descriptor means today's +behaviour exactly, so a released payload keeps working and there is no flag +day. A descriptor that is PRESENT AND MALFORMED is refused, naming the file -- +otherwise a typo reads as "an older payload" and the engine silently uses the +hardcoded path for a layout that has moved. + +**It is not a general flag channel.** Three keys, each answering a question the +engine already asks. A payload cannot inject arbitrary compile flags: that is +`[build]` in a manifest, which is the project's to write, and a payload that +could would be a package changing a build it does not own. + +**The descriptor is the recipe's output, not a file in the archive.** Upstream +does not ship it and should not have to. The recipe writes it, which also means +the recipe's tests can assert its content -- the same place the layout facts +are already asserted. + +### What this does not do + +It does not make the NDK a fourth compiler family. `android-ndk` and `emsdk` +normalise to `Family::Llvm` because their compiler IS clang; the family answers +"which flag vocabulary does this compiler speak" and the payload name answers +"which archive provides it". Two axes, and this change touches neither. + +## D. iOS: an ecosystem compiler and a located SDK + +### The compiler is ours; only the SDK is Apple's + +This is the question that shrinks the whole item. iOS does NOT need Xcode's +clang. It needs: + +| | comes from | why | +|---|---|---| +| the compiler | `xim:llvm` | any sufficiently new clang emits arm64 Mach-O for an iOS deployment target | +| the C++ runtime | the payload's libc++ | as on every other Apple row | +| the **SDK** | the machine's Xcode | headers and stub libraries, not redistributable | +| running on a simulator | the machine's `simctl` | a proprietary runtime that exists only on its own OS | + +`aarch64-macos` is `verified` today on exactly this split, which is the +precedent: `xim:llvm` compiles, and the macOS SDK is located. + +### The mechanism exists + +`modules/platform/src/macos/macos.cppm` already has + +```cpp +std::optional sdk_path(); // xcrun --show-sdk-path +``` + +and it already tries `xcrun --sdk macosx --show-sdk-path` as its second form. +So the change is a **parameter**, not a concept: + +```cpp +std::optional sdk_path(std::string_view sdk = "macosx"); +// "iphoneos" | "iphonesimulator" +``` + +Three existing callers are unchanged by the default argument. + +### What `@system` means, and why it is not needed here + +`@system` marks a toolchain mcpp LOCATES rather than installs -- `msvc@system` +is the one instance, because MSVC is a compiler that cannot be redistributed. +`parse_toolchain_spec` refuses `@system` for every other family by name, and +that refusal is right: admitting `gcc@system` would cost hermeticity for no +reason, since gcc is packaged. + +The earlier design record proposed generalising it (R4) and then withdrew it. +**It stays withdrawn, and this item does not revive it.** The thing being +located for iOS is not a compiler, it is a **sysroot** -- and +`TargetInfo::sysroot` plus `[target.].sysroot` are an axis that already +exists. So: + +``` +row aarch64-ios pin = llvm@22.1.8 sysroot = +row aarch64-ios-sim pin = llvm@22.1.8 sysroot = +row x86_64-ios-sim pin = llvm@22.1.8 sysroot = +``` + +A locator on the sysroot axis cannot admit `gcc@system`, because it says +nothing about compilers. That is why this is the narrow change and R4 was not. + +### Deployment target + +The `-m…-version-min` flag differs between the two, which is the second reason +the simulator is its own row rather than a flag on the device row: + +``` +aarch64-ios -miphoneos-version-min= +*-ios-sim -mios-simulator-version-min= +``` + +The floor is the project's, on the axis that already carries +`macos_deployment_target` and `min_api_level` -- one slot in the fingerprint, +because a target is Apple or Android and never both. + +### Running it + +The engine learns nothing about simulators. `runner` is an argv prefix and the +session belongs to a package: + +```toml +[target.aarch64-ios-sim] +runner = ["simctl-run"] +``` + +`simctl-run` is a program from a new `xim:apple-simulator-tools`, and it owns +everything simulator-shaped: a bare Mach-O executable cannot be launched by +`simctl`, so the program wraps it in a minimal bundle, boots or reuses a +device, installs, launches, collects stdout and the exit code, and tears down. +That is the R5 boundary -- platform knowledge in the ecosystem -- and it is +also why the runner cannot be `xcrun simctl spawn` written into a manifest: a +manifest cannot express the bundle. + +The device row keeps `runner` unset. An artefact cannot be run off an iOS +device without a signature the developer owns, which is R12's separate subject. + +### The host surface, bounded and named + +This item adds exactly two host dependencies, both on macOS only: + +| what | why it is permitted | +|---|---| +| the iPhoneOS / iPhoneSimulator SDK, via `xcrun` | a proprietary runtime that exists only on its own OS -- category (b) of the recorded policy | +| `simctl`, via the runner program | the same category | + +Everything else is ecosystem: compiler, C++ runtime, linker, packaging. The +recorded rule is that a host surface must be minimal, named and written down, +and two named items on one host is the whole of it. Neither is a fallthrough: +each is reached deliberately, and its absence is an error that names it. + +## Task list and dependencies + +``` +A1 android-system-image: debugfs -> xim:7zip xim-pkgindex +A2 aarch64-linux-android -> verified + docs mcpp (independent) +B1 withdraw the `ndk` alias mcpp +C1 sdk_path(sdk) parameter mcpp (D depends) +C2 .mcpp-toolchain.json reader + malformed refusal mcpp +C3 android-ndk.lua writes the descriptor xim-pkgindex (after C2) +D1 three iOS rows: pin llvm, located sysroot mcpp (after C1) +D2 -m*-version-min per row mcpp (after D1) +D3 xim:apple-simulator-tools with simctl-run xim-pkgindex +D4 runner wired for the two sim rows mcpp (after D3) +D5 docs: 20-toolchains SDK section + zh, examples/13 mcpp +``` + +`A2`, `B1`, `C1`, `C2`, `D1`, `D2`, `D4`, `D5` are one mcpp PR with a version +number, per the single-PR rule. `A1`, `C3`, `D3` are one xim-pkgindex PR. `C3` +must land after `C2` ships, because a descriptor no engine reads is a file +nothing checks. + +## Criteria + +Each claim below fails when its subject is removed, which is the only reason to +write it down. + +| claim | criterion | +|---|---| +| A: the device row runs | qemu-user run in CI on a Linux runner, asserting `1-2-3` and exit 0; the extraction is the packaged 7zip, not a host tool | +| B: one spelling | `toolchain = "ndk@…"` is refused AT PARSE with "unknown toolchain", not by the capability gate | +| C: the engine stops knowing | delete `ndk_host_tag()`'s call site and the build still resolves, because the descriptor answered; and a malformed descriptor is refused naming the file | +| C: no flag day | a payload with no descriptor resolves exactly as today -- asserted against the released android-ndk | +| D: iOS builds | macOS runner, `xim:llvm` plus the located SDK, artefact is Mach-O arm64 with the iOS platform in `LC_BUILD_VERSION` | +| D: the simulator runs | macOS runner, `mcpp run --target aarch64-ios-sim` prints `1-2-3` | +| D: the host surface is bounded | on a macOS runner with `xcode-select` pointing nowhere, both iOS rows fail with a message naming the SDK -- and no other row changes | + +**One premise needs measuring before D is scheduled**: that GitHub's macOS +runners ship both an iOS SDK and a bootable simulator. If they ship the SDK but +no simulator, D1/D2 are still verifiable in CI and D4 is a local-only claim, +which changes the tier the sim rows can reach and nothing else in this design. + +## Deliberately not done + +- **`xim:iphoneos-sdk` as a package.** The earlier record listed it at + "whichever of three licence tiers applies". The SDK is not redistributable, + so the tier is the locator, and a package that only locates is a package that + ships nothing -- the row's `sysroot` column says it more directly. +- **Xcode's clang as the toolchain.** It would work and it is the wrong default: + it makes the compiler a host dependency where the ecosystem already has one, + and it would make the iOS rows the only Apple rows that do not use + `xim:llvm`. +- **Fixing `xim:e2fsprogs`.** Recorded, not owned here. Nothing else in the + index depends on its `debugfs`. +- **A device runner for `aarch64-ios`.** R12's subject, and it needs a + signature rather than a package. diff --git a/.agents/docs/README.md b/.agents/docs/README.md index 35999c10..53141301 100644 --- a/.agents/docs/README.md +++ b/.agents/docs/README.md @@ -18,7 +18,7 @@ superseded_by: 2026-09-07-....md # when status is superseded --- ``` -277 records. +278 records. ## By subject @@ -46,6 +46,7 @@ Records that declare one. Everything else is listed by date below. ### targets +- [SDK toolchains, the payload/engine seam, and iOS local verification](2026-09-11-sdk-toolchains-and-ios-local-verification.md) — active - [Where a platform's knowledge belongs: iOS, Android and Web across the engine, the index and the plugins](2026-09-11-platform-targets-design-review.md) — active ### triage @@ -57,6 +58,7 @@ Records that declare one. Everything else is listed by date below. ### 2026-09 - [Six open issues: what each one actually is, and what would answer it](2026-09-11-six-open-issues-analysis.md) — active +- [SDK toolchains, the payload/engine seam, and iOS local verification](2026-09-11-sdk-toolchains-and-ios-local-verification.md) — active - [Where a platform's knowledge belongs: iOS, Android and Web across the engine, the index and the plugins](2026-09-11-platform-targets-design-review.md) — active - [The category the plugin taxonomy does not name, and what a platform actually decomposes into](2026-09-11-distribution-plugins-and-platform-decomposition.md) — active - [Two answers and two silences: the scanner's second grammar, and the manifest keys nothing reads](2026-09-09-two-answers-and-two-silences.md) — active From 0995ea91447489eb7d933206308d2a14b82a5ab8 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:46:32 +0800 Subject: [PATCH 02/14] docs(design): openkal across the three platforms, and the plan's self-review The plan gains item E -- openkal's cross-build closing on iOS, Android and Web -- and the self-review the goal asks for. Retitled, because the document is no longer only about SDK toolchains. E is three different answers, and the spec is what makes the third legitimate. E1 Android DONE and merged. `openkal-linux` is written on the kernel's syscall interface and borrows nothing from a C library; Android's kernel IS Linux and sys.h branches on architecture, not OS. Green against the RELEASED 2026.9.11.3: both ABIs build, the objects name no C library symbol -- checked against a DIFFERENT libc, which is the half worth having -- and a program over openkal alone printed `openkal: 1-2-3` on an emulator. E2 iOS a consequence of D rather than new work: once the SDK resolves, `openkal-macos` compiles for the iOS rows unchanged. One asymmetry is recorded rather than papered over: `cfg(os = "linux")` covers Android because Android is an `env` on `linux`, while `cfg(os = "macos")` does NOT cover iOS because iOS is its own `os`. A `cfg(apple)` dimension was considered and rejected -- it would introduce a dimension for one consumer's convenience. E3 Web new software, and the smallest of the three: forwarding to a POSIX-shaped libc is thinner than issuing syscalls. The partial surface is CONFORMANT, by clause 6.2's three times -- provided and forwarding, provided with the capability word reporting what is exercisable, or NOT PROVIDED so the report is an undefined symbol at link. `process`, `exec` and `space` take the third: providing `kal_process_spawn` on wasm to return an error is the shape the spec names as a defect, because the caller cannot tell. E4 records that `examples/portable` is stale on EVERY target -- it fails to compile for the host with `unknown type name 'kal_spawn'`, identically to how it fails for Android -- so the example that exists to demonstrate portability builds nowhere, and that is a criterion rather than an assumption. The task list is now per-repo with its dependency edges, three of which are not reorderable, and it names the one split that is by construction rather than by choice: a new package is a new repository in this ecosystem. The self-review covers the nine angles. Two are worth reading: the one place simplicity was deliberately refused (E2's two cfg lines), and where the risk concentrates (E3, which is new code in a new package and therefore cannot regress anything that exists). --- ...k-toolchains-and-ios-local-verification.md | 246 ++++++++++++++++-- .agents/docs/README.md | 4 +- 2 files changed, 229 insertions(+), 21 deletions(-) diff --git a/.agents/docs/2026-09-11-sdk-toolchains-and-ios-local-verification.md b/.agents/docs/2026-09-11-sdk-toolchains-and-ios-local-verification.md index 4f361f21..52259197 100644 --- a/.agents/docs/2026-09-11-sdk-toolchains-and-ios-local-verification.md +++ b/.agents/docs/2026-09-11-sdk-toolchains-and-ios-local-verification.md @@ -3,13 +3,16 @@ subject: targets status: active --- -# SDK toolchains, the payload/engine seam, and iOS local verification +# SDK toolchains, the payload/engine seam, and openkal across iOS, Android and Web -**Status:** design, for review. Nothing here is implemented. +**Status:** design. Items A and E1 are measured and merged; the rest is for +review before implementation. -**Scope.** Four items, in dependency order. The first is measured and only needs +**Scope.** Five items, in dependency order. The first is measured and only needs writing down; the second removes something rather than adding; the third is the -only structural change; the fourth is the one with a new capability. +only structural change in the engine; the fourth adds a platform; the fifth +closes the same three platforms for openkal, where one is already done, one is +a reuse and one is new software. | | item | shape | |---|---|---| @@ -17,6 +20,7 @@ only structural change; the fourth is the one with a new capability. | B | the `ndk` toolchain alias is withdrawn | deletion | | C | a payload describes itself, so the engine stops knowing the NDK | new contract at an existing seam | | D | iOS builds and runs on the simulator, locally and in CI | one parameter, one row column, one runner package | +| E | openkal's cross-build closes on all three platforms, with examples and docs | one shared, one located, one new implementation | ## A. Android's device row has an execution path after all @@ -254,26 +258,226 @@ recorded rule is that a host surface must be minimal, named and written down, and two named items on one host is the whole of it. Neither is a fallthrough: each is reached deliberately, and its absence is an error that names it. +## E. openkal across the three platforms + +openkal is the other half of "a platform is supported": the target rows say a +COMPILER can emit for a platform, and openkal says a program written against +one interface can RUN there. The three platforms are three different answers, +and the spec's own capability model is what makes the third one legitimate +rather than a compromise. + +### E1. Android shares the Linux implementation — done + +`openkal-linux` is written on the Linux kernel's system-call interface and +borrows nothing from any C library, which is what lets it sit beneath one. +Android's kernel IS Linux, the per-architecture syscall ABI is identical, and +`src/sys.h` branches on `__x86_64__` / `__aarch64__` — the architecture, not +the operating system. + +So a portable program needs no new line: `cfg(os = "linux")` is true for an +Android triple, because Android is an `env` value on a `linux` OS. + +Measured and merged (`openkal-linux#26`), against the RELEASED +`mcpp 2026.9.11.3` rather than a working tree: + +``` +== aarch64-linux-android == 17 objects, ARM aarch64 +== x86_64-linux-android == 17 objects, x86-64 +both Android ABIs build from this implementation unchanged, and name no C library symbol +``` + +and a program written against openkal alone — no C library, no `import std` — +printed `openkal: 1-2-3` with exit 0 on an API 24 emulator image. + +The symbol check is the half worth having. Compiling is the weaker statement; +the property this package exists for is that its objects name no C library +symbol, and on Android the C library is a DIFFERENT one, so a reference that +resolved to glibc by habit would appear as a bionic name. Two compiler-runtime +names are permitted with their reason recorded, both measured to be defined in +the NDK's `libclang_rt.builtins--android.a` and in neither bionic +`libc.so`. + +### E2. iOS reuses the macOS implementation + +The same argument as Android, on Apple's side: iOS and macOS share the Darwin +kernel, and `openkal-macos` is arch-dispatched the same way. What blocked it +was never the implementation — it was that there was nothing to build against, +and item D is what supplies that. + +So E2 is a consequence of D rather than new work: with `sdk_path("iphoneos")` +and `sdk_path("iphonesimulator")` resolving, `openkal-macos` compiles for the +three iOS rows, and a `cfg` selection picks it up. The selection needs one +decision: `cfg(os = "ios")` is its own OS value, so a portable program's +`cfg(os = "macos")` line does NOT cover iOS the way `cfg(os = "linux")` covers +Android. The two are not symmetric, and the reason is the modelling decision +itself — Android is an `env` on `linux`, iOS is its own `os`. + +That asymmetry is correct and should not be papered over. A program targeting +both Apple platforms writes: + +```toml +[target.'cfg(os = "macos")'.dependencies] +openkal-macos = "0.7.0" + +[target.'cfg(os = "ios")'.dependencies] +openkal-macos = "0.7.0" +``` + +An alternative was considered and rejected: making `cfg(os = "macos")` true for +iOS, or adding a `cfg(apple)` predicate. `Triple::is_apple()` exists in the +engine for exactly this question, so a `cfg(apple)` dimension is expressible — +but it would be a NEW cfg dimension introduced for one consumer's convenience, +and the honest reading is that two OS values are two dependency lines. If a +third Apple platform arrives the case can be revisited with three consumers +rather than one. + +### E3. Web needs a new implementation, above a C library + +Emscripten is the one platform that changes the model. There is no kernel and +there are no system calls to issue: Emscripten supplies its own C library over +a JavaScript host. So an implementation cannot be written the way +`openkal-linux` is — BENEATH a C library — and must sit ABOVE one, which the +specification explicitly permits: "an implementation may be built upon a C +library, beneath one, or without one." + +`openkal-emscripten` is therefore new software rather than a sharing decision. +It is also the SMALLEST of the three implementations, because forwarding to a +POSIX-shaped libc is thinner than issuing syscalls: the Linux implementation is +3918 lines and the macOS one 3512, most of which is calling-convention and +kernel-structure detail that an above-libc implementation does not have. + +**The partial surface is legitimate, and the spec says how.** Clause 6.2 gives +three times, each the earliest at which the information exists: + +| time | mechanism | question | +|---|---|---| +| dependency resolution | the package declares what it provides | may this program be built against this implementation | +| link | an undefined symbol | was an interface used that the implementation does not provide | +| run | a capability word | how does it behave within an interface it provides | + +So the three groups of the 137-symbol surface get three different treatments, +and none of them is "present and always fails" — which the spec names as a +defect *because the caller cannot tell*: + +| group | treatment | why | +|---|---|---| +| `stream`, `fs`, `time`, `env`, `memory`, `random`, `abort`, `terminal` | provided, forwarding to Emscripten's libc | MEMFS and the JS host serve all of these | +| `net`, `datagram`, `task`, `timeout` | provided, with the capability word reporting what is exercisable | sockets exist but are WebSocket-shaped; threads exist but need a link flag. `KAL_EXEC_PROP_AVAILABLE` is the precedent: the interface is provided either way and whether it can be exercised is read | +| `process`, `exec`, `space` | NOT PROVIDED | there is no fork, no exec and no second address space. A program that uses one fails at LINK naming the symbol, which is clause 6.2's second time and is the mechanism rather than a defect | + +The third row is the design decision worth stating plainly: **an absent symbol +is the report.** Providing `kal_process_spawn` on wasm so that it returns an +error would be the shape the spec forbids, and it would also be undetectable +until run time on a platform where the answer is known at link time. + +### E4. The examples and the documentation + +**The `portable` example is stale on every target, not just the new ones.** +Measured: `openkal/examples/portable` fails to compile for the host with +`unknown type name 'kal_spawn'` — it pins `openkal = "0.10.0"` while using an +API from a different version, and it fails IDENTICALLY on `x86_64-linux-android`. +So the example that exists to demonstrate portability does not build anywhere, +and that has to be fixed before it can demonstrate three more platforms. + +Once it builds, it gains the three platform legs as `cfg` dependency lines and +a README recording what was measured on each — which is the same discipline +`examples/13-platform-targets` follows in mcpp: every claim in the README is +measured against the artefact the README describes, not carried over from +another one. + +`docs/24-openkal-cross.md` (and its Chinese copy) already carries an +"Android, Web and iOS under this model" section written when Web had no +implementation. It needs the one change that makes it current: the Web row +stops reading "needs an implementation written above a C library" and starts +naming `openkal-emscripten`, with the three-group table above as the reason a +partial surface is a conformant one. + +## Self-review across the angles + +Recorded because the plan is large enough that an angle left unexamined is an +angle that fails late. + +**Architecture.** Item C is the only structural change, and it moves knowledge +in the direction the rest of the ecosystem already flows: the recipe that +installs a payload is the thing that knows its layout. Items A, B, D and E2 +remove or reuse rather than add. E3 adds a package, not a mechanism. + +**Stability.** C's descriptor is additive and absence is today's behaviour, so +no released payload changes meaning. D touches a function with three existing +callers behind a default argument. The risk concentrates in E3, which is new +code — and new code in a new package cannot regress anything that exists. + +**Elegance and simplicity.** B is a deletion. D turns out to be a parameter +rather than a concept, because the SDK locator already exists. The one place +simplicity was deliberately refused is E2's two `cfg` lines: a `cfg(apple)` +dimension would be shorter to write and would introduce a dimension for one +consumer. + +**User experience.** Nothing has to be declared for any of the five platforms: +the row's pin is the default and installs on demand. What a user writes is the +deployment floor and, for a program over openkal, one `cfg` line per platform. +The refusals carry their own row's reason, which is what B and the capability +gate are for. + +**Compatibility and seamless upgrade.** No floor moves. `index.toml`'s +`min_mcpp` is untouched, so a client stopped at an older engine keeps reading +the whole index. The engine's CI pin moves in the repositories whose CI needs +the new rows, which is the distinction between a pin and a floor. + +**Cross-platform.** The host surface this plan adds is two named items on one +host (the iOS SDK via `xcrun`, `simctl` via the runner), both category (b) of +the recorded policy. Everything else resolves through the ecosystem. Three of +the five items are verified on hosts other than the one I am working from, +which is where the last several defects in this area came from. + +**Consistency.** One name per payload after B. One slot in the fingerprint for +the deployment floor, because a target is Apple or Android and never both. One +tier vocabulary across four documents, now enforced by +`check_target_tiers.py`. + +**Test coverage.** Every claim has a criterion that fails when its subject is +removed, listed below. The two that cannot be measured from a Linux machine are +named as such, with the runner that measures them instead. + ## Task list and dependencies ``` -A1 android-system-image: debugfs -> xim:7zip xim-pkgindex -A2 aarch64-linux-android -> verified + docs mcpp (independent) -B1 withdraw the `ndk` alias mcpp -C1 sdk_path(sdk) parameter mcpp (D depends) -C2 .mcpp-toolchain.json reader + malformed refusal mcpp -C3 android-ndk.lua writes the descriptor xim-pkgindex (after C2) -D1 three iOS rows: pin llvm, located sysroot mcpp (after C1) -D2 -m*-version-min per row mcpp (after D1) -D3 xim:apple-simulator-tools with simctl-run xim-pkgindex -D4 runner wired for the two sim rows mcpp (after D3) -D5 docs: 20-toolchains SDK section + zh, examples/13 mcpp +repo id task depends on +-------------- -- ------------------------------------------ ---------- +xim-pkgindex A1 android-system-image: debugfs -> xim:7zip - +mcpp A2 aarch64-linux-android -> verified + 4 docs - +mcpp B1 withdraw the `ndk` alias - +mcpp C1 sdk_path(sdk) parameter - +mcpp C2 .mcpp-toolchain.json reader + refusal - +xim-pkgindex C3 android-ndk.lua writes the descriptor C2 shipped +mcpp D1 three iOS rows: llvm pin, located sysroot C1 +mcpp D2 -m*-version-min per row D1 +xim-pkgindex D3 xim:apple-simulator-tools (simctl-run) - +mcpp D4 runner wired for the two simulator rows D3 +mcpp D5 docs: 20-toolchains + zh, examples/13 A2 D1 +openkal E3a openkal-emscripten: the new implementation - +openkal E3b its conformance + independence CI E3a +openkal E4a fix examples/portable (stale on every host) - +openkal E4b examples/portable: three platform legs E4a E3a D1 +mcpp E4c docs/24-openkal-cross + zh: name the Web impl E3a ``` -`A2`, `B1`, `C1`, `C2`, `D1`, `D2`, `D4`, `D5` are one mcpp PR with a version -number, per the single-PR rule. `A1`, `C3`, `D3` are one xim-pkgindex PR. `C3` -must land after `C2` ships, because a descriptor no engine reads is a file -nothing checks. +**Per-repo single PRs.** `A2 B1 C1 C2 D1 D2 D4 D5 E4c` are one mcpp PR with a +version number -- #612, retitled. `A1 C3 D3` are one xim-pkgindex PR. +`E3a E3b E4a E4b` are one openkal-side PR, except that `openkal-emscripten` is +a NEW PACKAGE and therefore its own repository by this ecosystem's convention +(one package, one repository) -- which is a split by construction rather than +by choice. + +**Three edges are not reorderable.** `C3` after `C2` ships, because a +descriptor no engine reads is a file nothing checks. `D4` after `D3`, because a +`runner` naming a program no package provides is a manifest that cannot +resolve. `E4b` after `D1`, because the example cannot have an iOS leg before +the rows can build. + +**E2 has no task.** It is a consequence of `D1`: once the SDK resolves, +`openkal-macos` compiles for the iOS rows unchanged, and the only artefact is +two `cfg` lines in `E4b`'s example. ## Criteria @@ -289,6 +493,10 @@ write it down. | D: iOS builds | macOS runner, `xim:llvm` plus the located SDK, artefact is Mach-O arm64 with the iOS platform in `LC_BUILD_VERSION` | | D: the simulator runs | macOS runner, `mcpp run --target aarch64-ios-sim` prints `1-2-3` | | D: the host surface is bounded | on a macOS runner with `xcode-select` pointing nowhere, both iOS rows fail with a message naming the SDK -- and no other row changes | +| E1: Android shares the implementation | merged and green against the RELEASED engine: both ABIs build, objects name no C library symbol, and a program over openkal alone ran on an emulator | +| E2: iOS reuses it | `openkal-macos` compiles for the three iOS rows on a macOS runner, and its objects name no C library symbol -- the same check the Android leg applies, against a third libc | +| E3: the Web implementation conforms | the conformance suite passes for the groups it provides; and a program using `kal_process_spawn` fails at LINK naming the symbol, which is the criterion that the absent groups are absent rather than present-and-failing | +| E4: the examples build | `examples/portable` builds for the host and for all five platforms it names -- it currently builds for NONE, which is why this is a criterion and not an assumption | **One premise needs measuring before D is scheduled**: that GitHub's macOS runners ship both an iOS SDK and a bootable simulator. If they ship the SDK but diff --git a/.agents/docs/README.md b/.agents/docs/README.md index 53141301..76e82a0f 100644 --- a/.agents/docs/README.md +++ b/.agents/docs/README.md @@ -46,7 +46,7 @@ Records that declare one. Everything else is listed by date below. ### targets -- [SDK toolchains, the payload/engine seam, and iOS local verification](2026-09-11-sdk-toolchains-and-ios-local-verification.md) — active +- [SDK toolchains, the payload/engine seam, and openkal across iOS, Android and Web](2026-09-11-sdk-toolchains-and-ios-local-verification.md) — active - [Where a platform's knowledge belongs: iOS, Android and Web across the engine, the index and the plugins](2026-09-11-platform-targets-design-review.md) — active ### triage @@ -58,7 +58,7 @@ Records that declare one. Everything else is listed by date below. ### 2026-09 - [Six open issues: what each one actually is, and what would answer it](2026-09-11-six-open-issues-analysis.md) — active -- [SDK toolchains, the payload/engine seam, and iOS local verification](2026-09-11-sdk-toolchains-and-ios-local-verification.md) — active +- [SDK toolchains, the payload/engine seam, and openkal across iOS, Android and Web](2026-09-11-sdk-toolchains-and-ios-local-verification.md) — active - [Where a platform's knowledge belongs: iOS, Android and Web across the engine, the index and the plugins](2026-09-11-platform-targets-design-review.md) — active - [The category the plugin taxonomy does not name, and what a platform actually decomposes into](2026-09-11-distribution-plugins-and-platform-decomposition.md) — active - [Two answers and two silences: the scanner's second grammar, and the manifest keys nothing reads](2026-09-09-two-answers-and-two-silences.md) — active From 200d5fe0fe91dbee015db9f349de43365005f428 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Fri, 11 Sep 2026 18:40:21 +0800 Subject: [PATCH 03/14] feat(toolchain): one payload name, a payload that describes itself, and the SDK axis iOS needs B1. WITHDRAW THE `ndk` ALIAS. It parsed and the capability gate then refused it, because that gate compares the declared spelling against the row's pin -- `android-ndk@30.0.16248370` -- and `ndk@30.0.16248370` does not contain it. So a name was good enough for the parser and not for the build, which reads as a defect rather than a naming choice. Withdrawn rather than completed: teaching the gate to normalise payload names would make two spellings work through a second mechanism, where one name keeps the comparison correct by construction. The name kept is the one the index uses. C1. `sdk_path(sdk)`. macOS located exactly one SDK because macOS was the only Apple target; the iOS rows need iPhoneOS and iPhoneSimulator, and the difference between the three is directory names. So it is a parameter over a table, not a second locator. Two pure pieces carry what can be wrong in it and are checked on any host: `sdk_layout` (the per-SDK directory names, and which a Command-Line-Tools install ships) and `sdkroot_answers` -- because `SDKROOT` names ONE SDK and there are now three questions. An override that names an SDK answers for that SDK alone; one that names none answers the default question only, which is what keeps today's three callers unchanged. C2. `.mcpp-toolchain.json`: A PAYLOAD DESCRIBES ITSELF. Three payload-specific facts lived in the engine -- the NDK's `toolchains/llvm/prebuilt//bin` layout, its API floor in `meta/platforms.json`, and the `-D__BIONIC_CTYPE_INLINE=` its libc++ module surface needs -- and each is a fact the installing recipe already computes for its own probes. The recipe now writes one file beside the payload and the engine reads it: `frontend`, `platform_floor`, `std_module_defines`. Three properties. Absence is compatibility: no descriptor means today's behaviour exactly, so a released payload keeps working and there is no flag day. Present and MALFORMED is refused naming the file -- otherwise a typo reads as "an older payload" and the engine uses a hardcoded path for a layout that has moved. And it is not a general flag channel: three keys, each answering a question the engine already asks, with `frontend` refused if it leaves the payload root and a define refused if it could begin an option. `payload_frontend` therefore returns `expected`, so no caller can express a malformed descriptor as "not found"; the eight call sites propagate it, and `toolchain list` and `doctor` report it rather than skipping the payload. D (partial). `ios_deployment_target` joins `macos_deployment_target` as the second Apple key: same single `minPlatformVersion` slot, different version space, since "14.0" is a macOS version and means nothing to an iOS SDK. The effective triple now carries it -- `arm64-apple-ios18.0` and `arm64-apple-ios18.0-simulator`, Apple's own spellings -- which is the one place that says it. `is_ios()` and `is_ios_simulator()` name the rows. A third copy of a false claim, corrected: `minApiLevel`'s comment still said an unset level "means the NDK's own default, which `clang -target aarch64-linux-android` normalises to". bionic refuses an unversioned triple. The two earlier copies were fixed in `min_platform_version` and `llvm_triple`; this one was written on another day and outlived both. THE FIFTH COPY OF THE TIER TABLE WAS IN A TEST. `aarch64-linux-android` became `verified`, the row and all four documents moved, `check_target_tiers.py` reported agreement -- and `test_toolchain_triple.cpp` went on asserting `preview`, because a literal in a test is in neither set. The test's tier claims are now one-line pairs and the checker reads it as a fifth document; removing the fix makes the checker fail, which is the only reason to add it. And a measurement, not yet a claim: ci-macos-ios.yml asks the runner what it actually provides -- both located SDKs, whether a simulator boots, whether `simctl spawn` takes a bare Mach-O, whether the SDK's libc++ or the payload's static archive links, and whether `import std` precompiles against the located SDK. The design record schedules the iOS rows on those answers; none of them can be reasoned out from a Linux desk. --- .github/tools/check_target_tiers.py | 49 ++++- .github/workflows/ci-macos-ios.yml | 177 +++++++++++++++++ README.md | 2 +- README.zh-CN.md | 2 +- docs/21-the-target-triple.md | 14 +- docs/zh/21-the-target-triple.md | 12 +- mcpp.toml | 2 +- modules/manifest/src/toml.cppm | 5 + modules/manifest/src/types.cppm | 41 +++- modules/platform/src/macos/macos.cppm | 137 +++++++++++-- modules/toolchain-model/src/triple.cppm | 56 ++++-- modules/versioning/src/version.cppm | 2 +- src/build/prepare.cppm | 70 ++++++- src/doctor.cppm | 10 +- src/project.cppm | 2 + src/toolchain/compat.cppm | 14 +- src/toolchain/lifecycle.cppm | 18 +- src/toolchain/registry.cppm | 226 ++++++++++++++++++++- tests/unit/test_platform_apple_sdk.cpp | 117 +++++++++++ tests/unit/test_toolchain_descriptor.cpp | 239 +++++++++++++++++++++++ tests/unit/test_toolchain_msvc.cpp | 8 +- tests/unit/test_toolchain_registry.cpp | 53 +++++ tests/unit/test_toolchain_triple.cpp | 39 ++-- 23 files changed, 1205 insertions(+), 90 deletions(-) create mode 100644 .github/workflows/ci-macos-ios.yml create mode 100644 tests/unit/test_platform_apple_sdk.cpp create mode 100644 tests/unit/test_toolchain_descriptor.cpp diff --git a/.github/tools/check_target_tiers.py b/.github/tools/check_target_tiers.py index 23e32abc..d156cf9c 100755 --- a/.github/tools/check_target_tiers.py +++ b/.github/tools/check_target_tiers.py @@ -40,6 +40,22 @@ ROOT / "docs/zh/21-the-target-triple.md", ] +# THE FIFTH COPY WAS IN A TEST, AND A CHECK OVER DOCUMENTS CANNOT SEE IT. +# +# `aarch64-linux-android` became `verified`; the row moved, all four documents +# moved, this script reported "OK: 29 target tiers agree across 4 documents" +# -- and `test_toolchain_triple.cpp` went on asserting `preview`, because a +# literal in a test is in neither set. It was caught by running the suite, +# which is luck rather than a check. +# +# So the test file is a fifth document here. Its tier claims are written as +# `std::pair{"", ""}` for exactly this reason: one line carrying +# both halves is a shape this script can read, and the alternative -- a target +# named on one line and its tier asserted three lines below -- is not. +tests = [ + ROOT / "tests/unit/test_toolchain_triple.cpp", +] + fail = False for doc in docs: if not doc.exists(): @@ -75,6 +91,37 @@ fail = True print(f" {doc.relative_to(ROOT)}: {len(seen)} of {len(rows)} rows") +# The test file, by the one-line rule described above. Unlike a document it is +# not required to name every row: a test states the claims it has evidence for, +# and a row with no assertion is not a row asserted wrongly. What IS required +# is that every claim it does make agrees. +for t in tests: + if not t.exists(): + print(f"ERROR: {t.relative_to(ROOT)} is missing") + fail = True + continue + claimed = {} + for line in t.read_text().splitlines(): + lits = re.findall(r'"([A-Za-z0-9_.+-]+)"', line) + tier = next((l for l in lits if l in TIERS), None) + if tier is None: + continue + for name in lits: + if name in rows: + claimed[name] = tier + if not claimed: + print(f"ERROR: {t.relative_to(ROOT)} is listed here but claims no " + f"tier; either its assertions changed shape or this list is stale") + fail = True + continue + for name, tier in sorted(claimed.items()): + if rows[name] != tier: + print(f"ERROR: {t.relative_to(ROOT)}: {name} asserted as " + f"'{tier}', the table says '{rows[name]}'") + fail = True + print(f" {t.relative_to(ROOT)}: {len(claimed)} row(s) claimed") + if fail: sys.exit(1) -print(f"OK: {len(rows)} target tiers agree across {len(docs)} documents") +print(f"OK: {len(rows)} target tiers agree across {len(docs)} documents " + f"and {len(tests)} test file(s)") diff --git a/.github/workflows/ci-macos-ios.yml b/.github/workflows/ci-macos-ios.yml new file mode 100644 index 00000000..aca0aa34 --- /dev/null +++ b/.github/workflows/ci-macos-ios.yml @@ -0,0 +1,177 @@ +name: ci-macos-ios + +# The iOS rows, measured on the only machine that can answer for them. +# +# iOS needs an ecosystem compiler and a LOCATED SDK: `xim:llvm` emits arm64 +# Mach-O for an iOS deployment target, and only the machine's Xcode can supply +# the iPhoneOS / iPhoneSimulator headers and stub libraries, which are not +# redistributable. The simulator runtime is the same category. So every claim +# about these three rows is a claim about a macOS runner, and this job is where +# they are made. +# +# Kept out of ci-macos.yml deliberately: that job asserts mcpp's default quiet +# output shape and tacking a differently-shaped leg onto it has broken that +# assertion before. + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + workflow_dispatch: + +concurrency: + group: ci-macos-ios-${{ github.ref }} + cancel-in-progress: true + +jobs: + ios-host-surface: + name: iOS - what this runner actually provides + runs-on: macos-15 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup-macos-llvm + + # THE PREMISE, STATED AND THEN MEASURED. The design record schedules the + # iOS rows on one assumption: that a GitHub macOS runner ships both an + # iOS SDK and a bootable simulator. If it ships the SDK but no simulator, + # the device row is still verifiable here and the two simulator rows + # become a local-only claim -- which changes the tier they can reach and + # nothing else. This step is the difference between those two worlds. + - name: "Host surface: the two located SDKs and the simulator runtime" + run: | + set -x + xcode-select -p + xcrun --sdk iphoneos --show-sdk-path + xcrun --sdk iphoneos --show-sdk-version + xcrun --sdk iphonesimulator --show-sdk-path + xcrun --sdk iphonesimulator --show-sdk-version + set +x + echo "--- runtimes ---" + xcrun simctl list runtimes + echo "--- devices available ---" + xcrun simctl list devices available + + # WHETHER A BARE MACH-O CAN BE RUN AT ALL, which decides how much the + # runner program has to do. `simctl launch` needs an installed .app + # bundle; `simctl spawn` takes an executable. If spawn works, the + # `simctl-run` program is a boot-and-spawn wrapper; if it does not, it + # has to synthesise a bundle, sign it and install it. Measuring this is + # cheaper than designing for the harder case. + - name: "Device: is one bootable, and does spawn take a bare executable" + run: | + set -uo pipefail + UDID=$(xcrun simctl list devices available \ + | grep -A50 -- '-- iOS' \ + | grep -m1 -oE '[0-9A-F]{8}-[0-9A-F-]{27}' || true) + echo "udid=[$UDID]" + if [ -z "$UDID" ]; then + echo "NO-IOS-SIMULATOR-DEVICE" + exit 0 + fi + xcrun simctl boot "$UDID" || true + xcrun simctl bootstatus "$UDID" -b || true + cat > /tmp/hello.cpp << 'CPP' + #include + int main() { std::puts("1-2-3"); return 0; } + CPP + SDK=$(xcrun --sdk iphonesimulator --show-sdk-path) + "$LLVM_ROOT/bin/clang++" -std=c++23 \ + -target arm64-apple-ios18.0-simulator \ + -isysroot "$SDK" -o /tmp/hello /tmp/hello.cpp + file /tmp/hello + otool -l /tmp/hello | grep -A5 LC_BUILD_VERSION || true + echo "--- simctl spawn on a bare Mach-O ---" + if xcrun simctl spawn "$UDID" /tmp/hello; then + echo "SPAWN-OK" + else + echo "SPAWN-FAILED exit=$?" + fi + + # THE DEVICE ROW'S ARTEFACT. Nothing runs it here -- that needs a + # signature the developer owns -- so the claim is about the ARTEFACT: + # arm64 Mach-O naming the iOS platform in LC_BUILD_VERSION. An artefact + # that says MACOS there is the failure this leg exists to catch, and it + # is invisible to a build that merely succeeds. + - name: "Device: the artefact names the iOS platform" + run: | + set -euo pipefail + cat > /tmp/dev.cpp << 'CPP' + #include + int main() { std::puts("1-2-3"); return 0; } + CPP + SDK=$(xcrun --sdk iphoneos --show-sdk-path) + "$LLVM_ROOT/bin/clang++" -std=c++23 -target arm64-apple-ios18.0 \ + -isysroot "$SDK" -o /tmp/dev /tmp/dev.cpp + file /tmp/dev + otool -l /tmp/dev | grep -A5 LC_BUILD_VERSION + + # THE C++ RUNTIME QUESTION, WHICH IS THE ONE THAT CAN SINK THIS. macOS + # links the PAYLOAD's static libc++ so that mcpp's deployment floor is + # real. That archive is built for macOS, and ld64 refuses an object built + # for one platform in a link for another -- so iOS may have to take + # libc++ from the SDK instead. Both are tried here, because "which one + # works" is the fact the flag builder needs and neither answer can be + # reasoned out from a Linux desk. + - name: "C++ runtime: SDK libc++ versus the payload static archive" + run: | + set -x + SDK=$(xcrun --sdk iphoneos --show-sdk-path) + cat > /tmp/cxx.cpp << 'CPP' + #include + #include + int main() { std::string s = "1-2-3"; std::puts(s.c_str()); return 0; } + CPP + echo "--- (a) SDK libc++, dynamic ---" + if "$LLVM_ROOT/bin/clang++" -std=c++23 -target arm64-apple-ios18.0 \ + -isysroot "$SDK" -o /tmp/cxx-sdk /tmp/cxx.cpp; then + otool -L /tmp/cxx-sdk + echo "SDK-LIBCXX-OK" + else + echo "SDK-LIBCXX-FAILED" + fi + echo "--- (b) payload static libc++ ---" + if "$LLVM_ROOT/bin/clang++" -std=c++23 -target arm64-apple-ios18.0 \ + -isysroot "$SDK" -nostdlib++ \ + "$LLVM_ROOT/lib/libc++.a" "$LLVM_ROOT/lib/libc++abi.a" \ + -o /tmp/cxx-static /tmp/cxx.cpp; then + echo "PAYLOAD-STATIC-OK" + else + echo "PAYLOAD-STATIC-FAILED" + fi + set +x + + # `import std` FOR iOS, which is mcpp's default and therefore the real + # bar. The module is precompiled from the PAYLOAD's libc++ headers + # against the LOCATED SDK's C library -- the same split macOS already + # uses, with a second SDK. If this cannot be made to work the iOS rows + # are a non-module tier and the documentation has to say so. + - name: "import std: precompile against the located SDK" + run: | + set -x + SDK=$(xcrun --sdk iphoneos --show-sdk-path) + STD_CPPM=$(find "$LLVM_ROOT" -name 'std.cppm' | head -1) + echo "std.cppm=$STD_CPPM" + MODDIR=$(dirname "$STD_CPPM") + if "$LLVM_ROOT/bin/clang++" -std=c++23 -target arm64-apple-ios18.0 \ + -isysroot "$SDK" -Wno-reserved-module-identifier \ + -Xclang -emit-reduced-module-interface \ + --precompile -o /tmp/std.pcm "$STD_CPPM" -I"$MODDIR"; then + echo "PRECOMPILE-OK" + else + echo "PRECOMPILE-FAILED" + fi + cat > /tmp/mod.cpp << 'CPP' + import std; + int main() { std::println("1-2-3"); return 0; } + CPP + if "$LLVM_ROOT/bin/clang++" -std=c++23 -target arm64-apple-ios18.0 \ + -isysroot "$SDK" -fmodule-file=std=/tmp/std.pcm \ + -o /tmp/mod /tmp/mod.cpp /tmp/std.pcm; then + echo "MODULE-LINK-OK" + else + echo "MODULE-LINK-FAILED" + fi + file /tmp/mod || true + set +x diff --git a/README.md b/README.md index 8c272087..9aa55310 100644 --- a/README.md +++ b/README.md @@ -421,7 +421,7 @@ list` reports for this machine): | `riscv64-linux-musl` · `aarch64-linux-gnu` · `x86_64-macos` | — | planned | | `wasm32-emscripten` | `emsdk@6.0.9` — Emscripten ships its own sysroot and its own libc++ module surface; `mcpp run` executes the module with `node` | verified | | `x86_64-linux-android` | `android-ndk@30.0.16248370` — bionic from the NDK, one payload for both ABIs; ran on an API 24 x86_64 emulator image | verified | -| `aarch64-linux-android` | the same payload and the same build; no execution path from an x86_64 host, because Google's emulator refuses a foreign guest | preview | +| `aarch64-linux-android` | the same payload and the same build; ran under qemu-user over the system image's own bionic, which the platform emulator cannot do from an x86_64 host | verified | | `aarch64-ios` · `aarch64-ios-sim` · `x86_64-ios-sim` | the iPhoneOS and iPhoneSimulator SDKs ship inside Xcode and are not redistributable, so the blocker is a licence rather than a payload | planned | `verified` an image has been built **and run** for the row, qemu and wine diff --git a/README.zh-CN.md b/README.zh-CN.md index 1d2dde9f..d3d3ae30 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -408,7 +408,7 @@ mcpp 的身份模型是两条正交轴:**工具链** = `family@version`(family | `riscv64-linux-musl` · `aarch64-linux-gnu` · `x86_64-macos` | — | planned | | `wasm32-emscripten` | `emsdk@6.0.9` —— Emscripten 自带 sysroot 和它自己的 libc++ 模块面;`mcpp run` 用 `node` 把模块跑起来 | verified | | `x86_64-linux-android` | `android-ndk@30.0.16248370` —— bionic 来自 NDK,一个载荷服务两个 ABI;在 API 24 的 x86_64 模拟器镜像上跑过 | verified | -| `aarch64-linux-android` | 同一个载荷、同样的构建;从 x86_64 宿主没有执行路径,因为 Google 的模拟器直接拒绝异构 guest | preview | +| `aarch64-linux-android` | 同一个载荷、同样的构建;在 qemu-user 上、配系统镜像自带的 bionic 跑过 —— 这是平台模拟器从 x86_64 宿主做不到的 | verified | | `aarch64-ios` · `aarch64-ios-sim` · `x86_64-ios-sim` | iPhoneOS 与 iPhoneSimulator 的 SDK 在 Xcode 里且不可再分发,所以阻塞项是许可而不是载荷 | planned | `verified` 该行的镜像已被构建**并运行**过,qemu 与 wine 都算 · `preview` 可构建 diff --git a/docs/21-the-target-triple.md b/docs/21-the-target-triple.md index 9b563f77..08e6aea0 100644 --- a/docs/21-the-target-triple.md +++ b/docs/21-the-target-triple.md @@ -485,7 +485,7 @@ other's rows. | `thumbv8m.main-none-eabihf` | preview | `llvm@22.1.8` | payload | payload | payload | payload | | `armv7a-none-eabi` | verified | `llvm@22.1.8` | payload | payload | payload | payload | | `armv7a-none-eabihf` | verified | `llvm@22.1.8` | payload | payload | payload | payload | -| `aarch64-linux-android` | preview | `android-ndk@30.0.16248370` | payload | payload | payload | — | +| `aarch64-linux-android` | verified | `android-ndk@30.0.16248370` | payload | payload | payload | — | | `x86_64-linux-android` | verified | `android-ndk@30.0.16248370` | payload | payload | payload | — | | `aarch64-ios` | planned | — | planned | planned | planned | planned | | `aarch64-ios-sim` | planned | — | planned | planned | planned | planned | @@ -526,12 +526,12 @@ constant stating it here is what the wasm row's own history shows going stale. A Windows user therefore sees the row, the pin resolves, and xim refuses with `no payload for this platform` before anything is fetched, naming the package. -**The two Android rows differ in tier because one of them was run.** An -x86_64 Android artefact executes on the platform's own emulator, and a -`verified` row means exactly that was done. The device row builds identically -and has no execution path from an x86_64 host: the emulator refuses a foreign -guest (`QEMU2 emulator does not support arm64 CPU architecture`), so it needs -an arm64 host or the qemu-user route. +**Both Android rows are `verified`, by different vehicles.** An x86_64 +artefact executes on the platform's own emulator. The device row's artefact runs +under qemu-user over the system image's own bionic, which is the route that +works from an x86_64 host -- the platform emulator refuses a foreign guest +(`QEMU2 emulator does not support arm64 CPU architecture`). A tier states that +an artefact was built and RUN; it does not state which emulator ran it. ### And CI measures every one of them diff --git a/docs/zh/21-the-target-triple.md b/docs/zh/21-the-target-triple.md index 592217ba..ad69bca2 100644 --- a/docs/zh/21-the-target-triple.md +++ b/docs/zh/21-the-target-triple.md @@ -435,7 +435,7 @@ CRT;图供给时是 `musl`。一个目标字符串,两个不同的 C 库 —— | `thumbv8m.main-none-eabihf` | preview | `llvm@22.1.8` | 载荷 | 载荷 | 载荷 | 载荷 | | `armv7a-none-eabi` | verified | `llvm@22.1.8` | 载荷 | 载荷 | 载荷 | 载荷 | | `armv7a-none-eabihf` | verified | `llvm@22.1.8` | 载荷 | 载荷 | 载荷 | 载荷 | -| `aarch64-linux-android` | preview | `android-ndk@30.0.16248370` | payload | payload | payload | — | +| `aarch64-linux-android` | verified | `android-ndk@30.0.16248370` | payload | payload | payload | — | | `x86_64-linux-android` | verified | `android-ndk@30.0.16248370` | payload | payload | payload | — | | `aarch64-ios` | planned | — | planned | planned | planned | planned | | `aarch64-ios-sim` | planned | — | planned | planned | planned | planned | @@ -472,11 +472,11 @@ CRT;图供给时是 `musl`。一个目标字符串,两个不同的 C 库 —— 一行自己的历史所展示的会变陈旧的东西。因此 Windows 用户会看到这一行、钉能解析, 而 xim 在任何东西被下载之前以 `no payload for this platform` 拒绝,并点名那个包。 -**两个 Android 行层级不同,是因为其中一个被运行过。** 一个 x86_64 的 Android 产物 -在平台自己的模拟器上执行得起来,而 `verified` 这个层级断言的正是「做过这件事」。 -真机那一行构建方式完全相同,而从一台 x86_64 宿主没有执行路径:模拟器直接拒绝异构 -guest(`QEMU2 emulator does not support arm64 CPU architecture`),所以它需要一台 -arm64 宿主,或者 qemu-user 那条路。 +**两个 Android 行都是 `verified`,而载具不同。** x86_64 的产物在平台自己的模拟器上 +执行。真机那一行的产物在 qemu-user 上、配系统镜像自带的 bionic 跑起来 —— 这是从一台 +x86_64 宿主行得通的路线,而平台模拟器会直接拒绝异构 guest +(`QEMU2 emulator does not support arm64 CPU architecture`)。一个层级断言的是产物 +被构建**并运行**过,它不断言是哪个模拟器运行的。 ### 而 CI 把每一台都测了 diff --git a/mcpp.toml b/mcpp.toml index 90d13140..e59ca832 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.9.11.3" +version = "2026.9.11.4" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/modules/manifest/src/toml.cppm b/modules/manifest/src/toml.cppm index 2a5bb867..220d1548 100644 --- a/modules/manifest/src/toml.cppm +++ b/modules/manifest/src/toml.cppm @@ -1936,6 +1936,8 @@ std::expected parse_string(std::string_view content, "and a SubOS's by that SubOS.")); if (auto v = doc->get_string("build.macos_deployment_target")) m.buildConfig.macosDeploymentTarget = *v; + if (auto v = doc->get_string("build.ios_deployment_target")) + m.buildConfig.iosDeploymentTarget = *v; // Surface unsupported [build] keys instead of silently dropping them. // #296 is #131's footgun one section over: `[build] defines` on an mcpp @@ -1953,6 +1955,7 @@ std::expected parse_string(std::string_view content, "dependency_linkage", "dialect_cxxflags", "flags", "include_dirs", "include_dirs_after", "private_include_dirs", + "ios_deployment_target", "jobs", "ldflags", "macos_deployment_target", "module_extensions", "profile", "sources", "static_stdlib", "target", // #540: read a few hundred lines above and, until now, absent here — @@ -3028,6 +3031,8 @@ std::expected parse_string(std::string_view content, b.dependencyLinkage = *v; if (auto v = doc->get_string("workspace.build.macos_deployment_target")) b.macosDeploymentTarget = *v; + if (auto v = doc->get_string("workspace.build.ios_deployment_target")) + b.iosDeploymentTarget = *v; static constexpr std::string_view kKnown[] = { "cflags", "cxxflags", "ldflags", "defines", "dialect_cxxflags", "include_dirs", "include_dirs_after", "private_include_dirs", diff --git a/modules/manifest/src/types.cppm b/modules/manifest/src/types.cppm index 8ea21761..aa7aa29b 100644 --- a/modules/manifest/src/types.cppm +++ b/modules/manifest/src/types.cppm @@ -783,6 +783,28 @@ struct BuildConfig : BuildInputs { // explicit per-invocation override) wins over this manifest default; // empty + no env = toolchain/SDK default. No effect off macOS. std::string macosDeploymentTarget; + // iOS minimum supported OS version, e.g. "18.0". THE SECOND APPLE + // PLATFORM, AND THEREFORE A SECOND KEY. + // + // It is not a second mechanism: both reach `llvm_triple` through the one + // `minPlatformVersion` parameter and one fingerprint slot, because a + // target is macOS or iOS and never both. What it is not is the same + // NUMBER -- "14.0" is a macOS version and means nothing to an iOS SDK -- + // so one key serving both would put two platforms' version spaces into + // one string, which is the mistake `x86_64-windows-musl` was added to + // undo one axis over. + // + // Applies to all three iOS rows. Apple's own model is one deployment + // target per PLATFORM, and the simulator is the same platform as the + // device; what differs between them is the flag's name + // (`-mios-simulator-version-min` versus `-miphoneos-version-min`), which + // is the flag builder's business and not the project's. + // + // Empty is legal and means the SDK's own default, which clang supplies + // for an Apple target. That is a measured property of these targets and + // not an assumption carried over from Android: bionic REFUSES an + // unversioned triple, and Darwin does not. + std::string iosDeploymentTarget; // Resolved build-profile knobs (from [profile.] + built-in defaults). std::string optLevel = "2"; // -O level bool debug = false; // -g @@ -1113,8 +1135,23 @@ struct TargetEntry { // effective aarch64-unknown-linux-android24 what clang is given // fingerprint carries the level so 21 and 24 are two directories // - // Zero means unset, which is legal and means the NDK's own default -- what - // `clang -target aarch64-linux-android` normalises to. + // ZERO MEANS UNSET, AND UNSET IS NOT THE SAME AS "THE NDK'S DEFAULT". + // + // This comment used to end "which is legal and means the NDK's own + // default -- what `clang -target aarch64-linux-android` normalises to", + // which is the THIRD copy of a claim that was measured false: + // + // sys/cdefs.h:365:2: error: Unversioned target triples are not + // supported! + // + // bionic refuses an unversioned triple, so there is no such normalisation + // and the level is mandatory. Unset therefore means "the project did not + // say", and the answer comes from the PAYLOAD -- `platform_floor` in its + // `.mcpp-toolchain.json`, or `meta/platforms.json` for a payload that + // ships no descriptor. The other two copies of the false sentence were in + // `min_platform_version` and in `llvm_triple`; this one was written on a + // different day and outlived both corrections, which is why the + // correction is recorded here rather than merely applied. int minApiLevel = 0; // NO per-role field here. There used to be a `cxxRuntimeTests`, and it was // parsed nowhere and applied nowhere — a configuration key that looked diff --git a/modules/platform/src/macos/macos.cppm b/modules/platform/src/macos/macos.cppm index 8322dc02..fa0ea00b 100644 --- a/modules/platform/src/macos/macos.cppm +++ b/modules/platform/src/macos/macos.cppm @@ -2,7 +2,9 @@ // // Provides: // has_xcode_clt() — detect Xcode Command Line Tools -// sdk_path() — discover macOS SDK via xcrun +// sdk_path(sdk) — discover an Apple SDK via xcrun ("macosx" default) +// sdk_layout(sdk) — the directory names that SDK uses inside Xcode +// sdk_name_of_root(p) — which SDK a path IS, for the SDKROOT override // runtime_lib_dirs() — macOS-specific library search paths // supports_full_static — macOS cannot fully static-link (libSystem) @@ -28,9 +30,49 @@ constexpr bool supports_full_static = false; // Returns true if `xcode-select -p` succeeds. bool has_xcode_clt(); -// Discover the macOS SDK path via `xcrun --show-sdk-path`. -// Returns the SDK path if found, or nullopt. -std::optional sdk_path(); +// THREE SDKS, ONE MACHINE. Apple ships the macOS, iPhoneOS and +// iPhoneSimulator SDKs from one developer directory, and mcpp locates +// rather than installs all three: they are not redistributable, which +// is why the iOS rows carry a located `sysroot` instead of a package. +// `sdk` is the name `xcrun --sdk` takes. +inline constexpr std::string_view sdk_macos = "macosx"; +inline constexpr std::string_view sdk_iphoneos = "iphoneos"; +inline constexpr std::string_view sdk_iphonesim = "iphonesimulator"; + +// Where an SDK lives inside a developer directory: the `.platform` +// directory that holds it, and its own `.sdk` directory name. Pure, and +// nullopt for a name no Apple SDK answers to. +struct SdkLayout { + std::string_view platformDir; // e.g. "iPhoneOS.platform" + std::string_view sdkDir; // e.g. "iPhoneOS.sdk" + bool inCommandLineTools; // the CLT-only install ships it +}; +std::optional sdk_layout(std::string_view sdk); + +// Which SDK a path IS, derived from its own directory name: +// `MacOSX15.4.sdk` -> "macosx", `iPhoneSimulator18.4.sdk` -> +// "iphonesimulator". Empty when the path does not name an SDK. +// +// THIS EXISTS BECAUSE `SDKROOT` NAMES ONE SDK AND THERE ARE THREE +// QUESTIONS. Honouring it for every request would answer an iOS query +// with a macOS SDK whenever a shell had it set -- a wrong sysroot, which +// fails later as missing headers rather than as a bad override. +std::string sdk_name_of_root(const std::filesystem::path& p); + +// Whether an `SDKROOT` override answers a request for `sdk`. +// +// A path that names an SDK answers for that SDK and no other. A path that +// names NO SDK -- a hand-rolled sysroot, which is a spelling clang accepts -- +// answers the DEFAULT question only: it is an answer to "the SDK", not to +// "the iPhoneOS SDK". That asymmetry is what keeps today's callers unchanged +// while making a specific request require positive identification. +bool sdkroot_answers(const std::filesystem::path& candidate, + std::string_view sdk); + +// Discover an Apple SDK path via `xcrun --show-sdk-path`. +// Returns the SDK path if found, or nullopt. The default argument is +// today's behaviour for today's callers. +std::optional sdk_path(std::string_view sdk = sdk_macos); // Built-in default deployment floor (rustc-style: every target has a // baseline). 14.0 = the floor of the official LLVM static libc++ @@ -107,18 +149,69 @@ bool has_xcode_clt() { #endif } -std::optional sdk_path() { +std::optional sdk_layout(std::string_view sdk) { + // ONE TABLE, THREE READERS. Every step of `sdk_path` below that names a + // directory reads it from here, so a fourth SDK is a row and not four + // edits. `inCommandLineTools` is false for the iOS SDKs because a + // CLT-only install genuinely does not ship them -- which is a reason the + // iOS rows can be refused, not a path to probe. + static constexpr SdkLayout kLayouts[] = { + { "MacOSX.platform", "MacOSX.sdk", true }, + { "iPhoneOS.platform", "iPhoneOS.sdk", false }, + { "iPhoneSimulator.platform", "iPhoneSimulator.sdk", false }, + }; + if (sdk == sdk_macos) return kLayouts[0]; + if (sdk == sdk_iphoneos) return kLayouts[1]; + if (sdk == sdk_iphonesim) return kLayouts[2]; + return std::nullopt; +} + +std::string sdk_name_of_root(const std::filesystem::path& p) { + // The installed name carries a version -- `MacOSX15.4.sdk` -- and the + // requested name does not. Lowercase, drop the suffix, drop the digits. + // + // THE SUFFIX IS REQUIRED, because this is positive identification and + // `.sdk` is the marker Apple's own naming gives it. A directory merely + // called `MacOSX` names no SDK; `sdkroot_answers` then routes it to the + // default question, which is what it was before this took a parameter. + std::string name = p.filename().string(); + for (auto& c : name) c = static_cast(std::tolower(c)); + if (!name.ends_with(".sdk")) return {}; + name.resize(name.size() - 4); + while (!name.empty() && (std::isdigit(static_cast(name.back())) + || name.back() == '.')) + name.pop_back(); + for (auto known : {sdk_macos, sdk_iphoneos, sdk_iphonesim}) + if (name == known) return std::string(known); + return {}; +} + +bool sdkroot_answers(const std::filesystem::path& candidate, + std::string_view sdk) { + if (!sdk_layout(sdk)) return false; + auto named = sdk_name_of_root(candidate); + if (!named.empty()) return named == sdk; + return sdk == sdk_macos; +} + +std::optional sdk_path(std::string_view sdk) { + auto layout = sdk_layout(sdk); + if (!layout) return std::nullopt; #if defined(__APPLE__) - // 1. Explicit override wins (matches clang's own SDKROOT handling). + // 1. Explicit override wins (matches clang's own SDKROOT handling) -- but + // only for a request it answers. See `sdkroot_answers`. if (const char* env = std::getenv("SDKROOT"); env && *env) { std::filesystem::path p(env); - if (std::filesystem::exists(p)) return p; + if (std::filesystem::exists(p) && sdkroot_answers(p, sdk)) return p; } - // 2. xcrun — the canonical query. Try the generic form, then the - // macosx-specific one (works even when the active developer dir's - // default SDK isn't macOS, e.g. an iOS-defaulted setup). - for (const char* cmd : {"xcrun --show-sdk-path 2>/dev/null", - "xcrun --sdk macosx --show-sdk-path 2>/dev/null"}) { + // 2. xcrun — the canonical query. The named form is the only one that can + // answer for a specific SDK; the generic form returns the ACTIVE + // default, so it is tried only for the default request, where it is + // today's first probe and stays first. + std::vector cmds; + if (sdk == sdk_macos) cmds.emplace_back("xcrun --show-sdk-path 2>/dev/null"); + cmds.push_back(std::format("xcrun --sdk {} --show-sdk-path 2>/dev/null", sdk)); + for (auto const& cmd : cmds) { auto result = run_capture_trimmed(cmd); if (!result.empty() && std::filesystem::exists(result)) return std::filesystem::path(result); @@ -129,18 +222,22 @@ std::optional sdk_path() { if (!devdir.empty()) { std::filesystem::path base(devdir); for (auto cand : { - base / "Platforms" / "MacOSX.platform" / "Developer" / "SDKs" / "MacOSX.sdk", - base / "SDKs" / "MacOSX.sdk" }) { + base / "Platforms" / layout->platformDir / "Developer" / "SDKs" + / layout->sdkDir, + base / "SDKs" / layout->sdkDir }) { if (std::filesystem::exists(cand)) return cand; } } // 4. Well-known fixed locations (Command-Line-Tools-only / standard Xcode). - for (const char* p : { - "/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk", - "/Applications/Xcode.app/Contents/Developer/Platforms/" - "MacOSX.platform/Developer/SDKs/MacOSX.sdk" }) { - if (std::filesystem::exists(p)) return std::filesystem::path(p); - } + std::vector fixed; + if (layout->inCommandLineTools) + fixed.emplace_back(std::filesystem::path( + "/Library/Developer/CommandLineTools/SDKs") / layout->sdkDir); + fixed.emplace_back(std::filesystem::path( + "/Applications/Xcode.app/Contents/Developer/Platforms") + / layout->platformDir / "Developer" / "SDKs" / layout->sdkDir); + for (auto const& cand : fixed) + if (std::filesystem::exists(cand)) return cand; #endif return std::nullopt; } diff --git a/modules/toolchain-model/src/triple.cppm b/modules/toolchain-model/src/triple.cppm index b754beb2..fc277acf 100644 --- a/modules/toolchain-model/src/triple.cppm +++ b/modules/toolchain-model/src/triple.cppm @@ -169,10 +169,17 @@ struct Triple { // what differs is the SDK and the deployment-target flag. // // NO VERSION IS BAKED IN, unlike the macOS branch above, and that is a - // decision rather than an omission. `-miphoneos-version-min` belongs to - // the layer that also owns the SDK path and the `.app` bundle -- a - // distribution plugin -- and a default written here would be a second - // place that answers it. clang picks its own when nothing says. + // decision rather than an omission. macOS carries a built-in floor + // because its static libc++ archives have one; iOS takes libc++ from + // the located SDK, which has no such constraint, so an unstated + // version means the SDK's own default -- which clang supplies for an + // Apple target. Android is the platform where that is false, and it + // is false there because bionic refuses an unversioned triple. + // + // THE PROJECT'S STATEMENT IS CARRIED WHEN THERE IS ONE, on the same + // parameter the other two platforms use: `arm64-apple-ios18.0` and + // `arm64-apple-ios18.0-simulator` are Apple's own spellings, with the + // version on the OS segment and the `-simulator` suffix after it. if (os == "ios") { const std::string a = (arch == "aarch64") ? "arm64" : arch; // AND THE SIMULATOR IS A DIFFERENT EFFECTIVE TRIPLE, WHICH IS WHY @@ -183,8 +190,9 @@ struct Triple { // object and the `-mios-simulator-version-min` flag all differ from // the device's, so folding the two into one identity would be the // mistake `x86_64-windows-musl` was added to undo. - if (env == "sim") return a + "-apple-ios-simulator"; - return a + "-apple-ios"; + const std::string v = std::string(minPlatformVersion); + if (env == "sim") return a + "-apple-ios" + v + "-simulator"; + return a + "-apple-ios" + v; } // ANDROID IS LINUX, AND THE ENV SEGMENT IS WHERE IT SAYS SO -- with the // API level fused onto it when the project stated one. @@ -284,6 +292,13 @@ struct Triple { // deployment-target flag. A site that means "Apple" and asks "macOS" gets // iOS wrong in the direction that still links. bool is_apple() const { return os == "macos" || os == "ios"; } + // APPLE'S OTHER OS, AND THE SIMULATOR IS THE SAME OS. Three rows answer + // true: the device and both simulator arches. What separates the + // simulator is `env == "sim"`, which changes the SDK, the effective + // triple's OS segment and the name of the deployment-target flag -- not + // the platform. + bool is_ios() const { return os == "ios"; } + bool is_ios_simulator() const { return os == "ios" && env == "sim"; } // Android is Linux with a different C library and a different loader path. // `os` stays `linux` for that reason -- it is the kernel, and every // Linux-shaped decision in the tree is right about it -- and the env @@ -652,7 +667,7 @@ inline constexpr TargetInfo kKnownTargets[] = { // payload exists yet -- `xim:android-ndk` is the row's whole remaining // cost, and until it lands `[target.].sysroot` is the escape hatch // for a machine that has an NDK already. - { "aarch64-linux-android", "preview", "", "android-ndk@30.0.16248370", "", false }, + { "aarch64-linux-android", "verified", "", "android-ndk@30.0.16248370", "", false }, // The emulator's row, and the one of the pair that could be EXECUTED. // // Not a convenience: x86_64 is what an Android emulator image runs, so a @@ -668,17 +683,24 @@ inline constexpr TargetInfo kKnownTargets[] = { // row is `verified` while `aarch64-linux-android` is `preview`, and the // difference is execution rather than confidence in the build. // - // WHY THE DEVICE ROW COULD NOT FOLLOW, recorded so the next attempt does - // not repeat it. Google's emulator refuses a foreign guest outright -- - // "QEMU2 emulator does not support arm64 CPU architecture" -- so the arm64 - // image needs an arm64 host. The documented fallback is qemu-user with the - // system image's own bionic, and preparing it needs four files extracted - // from an ext4 partition image by `debugfs`, which is the one program in - // `xim:e2fsprogs@1.47.3` that is a broken build (SIGFPE on every + // AND THE DEVICE ROW FOLLOWED, BY A DIFFERENT VEHICLE. Google's emulator + // refuses a foreign guest outright -- "QEMU2 emulator does not support + // arm64 CPU architecture" -- so the arm64 image needs an arm64 host. The + // route that works from an x86_64 one is qemu-user over the system image's + // own bionic, and what blocked it was never the route: preparing it needs + // four files out of an ext4 partition image, `debugfs` was the tool, and + // `xim:e2fsprogs@1.47.3`'s debugfs is a broken build (SIGFPE on every // filesystem-opening command, while dumpe2fs/e2fsck/tune2fs from the same - // payload work). That is an ecosystem defect with its own record in the - // index, not an engine gap, and it moves this row to `verified` when it is - // fixed -- nothing here changes. + // payload work). `xim:7zip` reads ext4 directly. Measured 2026-09-11: + // + // 7zz x bin/linker64 lib64/lib{c,dl,m,c++}.so + // qemu-aarch64-static -L + // -> 1-2-3 exit 0 + // + // So both rows are `verified` and the vehicles differ: the platform's own + // emulator for x86_64, qemu-user plus the image's bionic for aarch64. A + // tier states that an artefact was built and RUN, not which emulator ran + // it. // // One linker warning is worth recording because a user will see it and it // is not a defect: `unsupported flags DT_FLAGS_1=0x8000001`. API 24's diff --git a/modules/versioning/src/version.cppm b/modules/versioning/src/version.cppm index 9d997683..64a9af32 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.11.3"; +inline constexpr std::string_view MCPP_VERSION = "2026.9.11.4"; } // namespace mcpp diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index b4b3fa1a..7fd95dba 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -1589,11 +1589,39 @@ std::string min_platform_version(const mcpp::manifest::Manifest& m, // default -- the payload's own answer, which moves when the payload // does. macOS is the same shape and already works this way: its // default comes from the platform module, not from the manifest. + // THE PAYLOAD'S OWN ANSWER FIRST, AND THE ENGINE'S DERIVATION AS + // THE FALLBACK. `platform_floor` in `.mcpp-toolchain.json` is the + // same number by a channel that does not require this engine to know + // that an NDK keeps it in `meta/platforms.json`, nor that file's + // schema. A payload shipping no descriptor still resolves, which is + // what makes the descriptor additive. + // + // A MALFORMED descriptor is read as absence HERE ONLY, because this + // function has no error channel and does not need one: a + // payload-provided compiler reaches this point through + // `payload_frontend`, which refuses a malformed descriptor by name + // before any of these decisions are made. + if (auto desc = + mcpp::toolchain::payload_descriptor_for_compiler(compilerPath); + desc && *desc && !(*desc)->platformFloor.empty()) + return (*desc)->platformFloor; if (auto level = mcpp::toolchain::ndk_min_api_level(compilerPath); level > 0) return std::to_string(level); return {}; // the caller refuses; see android_api_level_refusal } + // APPLE'S TWO PLATFORMS ANSWER FROM TWO KEYS, ONE SLOT. + // + // "14.0" is a macOS version and means nothing to an iOS SDK, so the + // project states them separately -- and only one of them can apply to any + // one target, which is why they still share this function's single return + // and the single fingerprint slot behind it. + // + // Empty is a legal answer here and not a refusal, unlike Android's. The + // asymmetry is a measured property of the platforms rather than a policy: + // Darwin's driver supplies the SDK's own deployment target when the + // triple carries none, and bionic rejects the unversioned triple outright. + if (t.is_ios()) return m.buildConfig.iosDeploymentTarget; return mcpp::platform::macos::deployment_target( m.buildConfig.macosDeploymentTarget); } @@ -2937,7 +2965,12 @@ prepare_build(bool print_fingerprint, "{} → msvc {} ({})", spec->display(), inst->display_version(), inst->clPath.string())); } else { - explicit_compiler = mcpp::toolchain::payload_frontend(payload->root, pkg); + auto frontendR = mcpp::toolchain::payload_frontend(payload->root, pkg); + // A payload that describes itself and describes itself wrongly is + // refused by name -- not reported as a missing frontend, which is + // a different repair. + if (!frontendR) return std::unexpected(frontendR.error()); + explicit_compiler = *frontendR; if (!std::filesystem::exists(explicit_compiler)) { return std::unexpected(std::format( "toolchain payload '{}' has no known C++ frontend in {}", @@ -3164,7 +3197,10 @@ prepare_build(bool print_fingerprint, " mcpp toolchain install {}", defaultSpec, payload.error().message, defaultSpec)); } - explicit_compiler = mcpp::toolchain::payload_frontend(payload->root, defaultPkg); + auto defaultFrontendR = + mcpp::toolchain::payload_frontend(payload->root, defaultPkg); + if (!defaultFrontendR) return std::unexpected(defaultFrontendR.error()); + explicit_compiler = *defaultFrontendR; if (!std::filesystem::exists(explicit_compiler)) { return std::unexpected(std::format( "default toolchain payload {} has no known C++ frontend in {}", @@ -3451,7 +3487,27 @@ prepare_build(bool print_fingerprint, // directly is entitled to bionic's inline definitions. // `xim:android-ndk`'s own install-time self-test reaches // the identical conclusion from the other direction. - tc->stdModuleTargetFlags += " -D__BIONIC_CTYPE_INLINE="; + // + // AND THE PAYLOAD MAY SAY SO ITSELF. The recipe applies + // this same define in that self-test, so it is a fact + // the payload already holds; `std_module_defines` in + // `.mcpp-toolchain.json` is the channel for it, and the + // define below is what a payload that ships no + // descriptor still gets. The two are not added + // together: a descriptor that names defines is the + // payload's complete answer for this channel, and + // appending to it would mean a payload could not + // withdraw a define this engine once needed. + auto stdDefines = [&]() -> std::vector { + auto desc = + mcpp::toolchain::payload_descriptor_for_compiler( + tc->binaryPath); + if (desc && *desc && !(*desc)->stdModuleDefines.empty()) + return (*desc)->stdModuleDefines; + return { "__BIONIC_CTYPE_INLINE=" }; + }(); + for (auto const& def : stdDefines) + tc->stdModuleTargetFlags += " -D" + def; } } } @@ -3654,8 +3710,10 @@ prepare_build(bool print_fingerprint, pins::kFirstRunWinGnu, payloadR.error().message, pins::kSuggestGccMingw, pins::kFirstRunWinGnuTarget)); } - explicit_compiler = + auto gnuFrontendR = mcpp::toolchain::payload_frontend(payloadR->root, gnuPkg); + if (!gnuFrontendR) return std::unexpected(gnuFrontendR.error()); + explicit_compiler = *gnuFrontendR; if (!std::filesystem::exists(explicit_compiler)) { return std::unexpected(std::format( "MinGW-w64 payload {} has no known C++ frontend in {}", @@ -3834,7 +3892,9 @@ prepare_build(bool print_fingerprint, "host toolchain for build.mcpp ('{}'): {}", *tcSpec, payload.error().message)); } - auto frontend = mcpp::toolchain::payload_frontend(payload->root, pkg); + auto frontendR = mcpp::toolchain::payload_frontend(payload->root, pkg); + if (!frontendR) return std::unexpected(frontendR.error()); + auto frontend = *frontendR; if (!std::filesystem::exists(frontend)) { return std::unexpected(std::format( "host toolchain payload '{}' has no known C++ frontend in {}", diff --git a/src/doctor.cppm b/src/doctor.cppm index 4268d562..167b8a13 100644 --- a/src/doctor.cppm +++ b/src/doctor.cppm @@ -439,9 +439,15 @@ export int doctor_report() { // installed msvc toolset. That is the defect #436 fixed // in `toolchain list`; doctor kept its own copy, so the // two commands disagreed about the same machine. - auto bin = mcpp::toolchain::payload_frontend( + auto binR = mcpp::toolchain::payload_frontend( vEntry.path(), mcpp::toolchain::to_xim_package(s)); - if (bin.empty()) continue; + // DOCTOR IS THE COMMAND FOR EXACTLY THIS. A payload whose + // own description does not parse is a finding, and + // skipping it would make the one command that reports on + // an installation silent about the thing that breaks it. + if (!binR) { warn(binR.error()); continue; } + if (binR->empty()) continue; + const auto& bin = *binR; sawAny = true; auto label = s.display(); diff --git a/src/project.cppm b/src/project.cppm index 990f0605..8cfc4004 100644 --- a/src/project.cppm +++ b/src/project.cppm @@ -281,6 +281,8 @@ export void inherit_workspace_build(mcpp::manifest::Manifest& member, if (b.dependencyLinkage.empty()) b.dependencyLinkage = w.dependencyLinkage; if (b.macosDeploymentTarget.empty()) b.macosDeploymentTarget = w.macosDeploymentTarget; + if (b.iosDeploymentTarget.empty()) + b.iosDeploymentTarget = w.iosDeploymentTarget; } } diff --git a/src/toolchain/compat.cppm b/src/toolchain/compat.cppm index 8bdd0b15..92065ca7 100644 --- a/src/toolchain/compat.cppm +++ b/src/toolchain/compat.cppm @@ -151,7 +151,19 @@ std::optional normalize_spec(std::string_view compilerIn, // a target: the arch arrives from `--target` or `[target.]`, and // pinning one here would make `android-ndk@` mean aarch64 to a reader // who typed it for x86_64. - if (compiler == "android-ndk" || compiler == "ndk") { + // + // ONE SPELLING. `ndk` was accepted here as an alias and the capability gate + // refuses it, because that gate compares the declared spelling against the + // ROW'S PIN -- `android-ndk@30.0.16248370` -- and `ndk@30.0.16248370` does + // not contain it. So the alias parsed and was then rejected at the point of + // use, which reads as a defect rather than as a naming choice. + // + // Withdrawn rather than completed. Teaching the gate to compare normalised + // payload names would make two spellings work and put the comparison in a + // second mechanism; one name makes the gate correct by construction. The + // name kept is the one the index uses, so there is a single string for this + // payload across the ecosystem. + if (compiler == "android-ndk") { out.family = "llvm"; out.payload = "android-ndk"; if (muslVersionSuffix) return std::nullopt; diff --git a/src/toolchain/lifecycle.cppm b/src/toolchain/lifecycle.cppm index e9342146..d293b5c2 100644 --- a/src/toolchain/lifecycle.cppm +++ b/src/toolchain/lifecycle.cppm @@ -491,8 +491,15 @@ export int toolchain_list(const mcpp::config::GlobalConfig& cfg, // four levels deeper, and asking for `root/bin` skipped every // installed toolset silently. auto bin = mcpp::toolchain::payload_frontend(vEntry.path(), pkg); - if (bin.empty()) continue; - payloads.push_back({ *id, s.version, bin }); + // A MALFORMED DESCRIPTOR IS REPORTED, NOT SKIPPED. This + // enumeration's `continue` means "nothing usable here", and a + // payload whose own description does not parse would then be + // simply absent from `toolchain list` -- the reading the + // descriptor was added to prevent, in the one command a user + // runs to find out what is installed. + if (!bin) { mcpp::ui::warning(bin.error()); continue; } + if (bin->empty()) continue; + payloads.push_back({ *id, s.version, *bin }); } } } @@ -1102,7 +1109,12 @@ export int toolchain_set_default(const mcpp::config::GlobalConfig& cfg, // // Same rule as everywhere else in this round: installed means usable, // not present. - if (mcpp::toolchain::payload_frontend(installDir, pkg).empty()) { + auto installedFrontend = mcpp::toolchain::payload_frontend(installDir, pkg); + if (!installedFrontend) { + mcpp::ui::error(installedFrontend.error()); + return 1; + } + if (installedFrontend->empty()) { // Before "not installed", check whether this is the retired // `msvc@` spelling — otherwise the advice is to // install a toolset that does not exist and never will. diff --git a/src/toolchain/registry.cppm b/src/toolchain/registry.cppm index eb36fa4b..0a60478c 100644 --- a/src/toolchain/registry.cppm +++ b/src/toolchain/registry.cppm @@ -188,8 +188,84 @@ std::filesystem::path toolchain_frontend(const std::filesystem::path& binDir, // perfectly well and then did not appear in the list. Empty = no frontend // here, which is the caller's cue to skip; a wrong LAYOUT and a missing // PAYLOAD had been reporting the same way. -std::filesystem::path payload_frontend(const std::filesystem::path& payloadRoot, - const XimToolchainPackage& pkg); +// A MALFORMED DESCRIPTOR IS AN ERROR AND A MISSING FRONTEND IS NOT, which is +// why this returns `expected` over a path that may still be empty. Empty = +// nothing found where we looked, the caller's long-standing cue to skip or to +// refuse in its own words. `unexpected` = the payload described itself and the +// description does not parse, which no caller can express as "not found". +std::expected +payload_frontend(const std::filesystem::path& payloadRoot, + const XimToolchainPackage& pkg); + +// ─── A payload describes itself ────────────────────────────────────────────── +// +// Three payload-specific facts used to live in this engine: the NDK's internal +// `toolchains/llvm/prebuilt//bin` layout, its API floor in +// `meta/platforms.json`, and the `-D__BIONIC_CTYPE_INLINE=` its libc++ module +// surface needs. Each is a fact the INSTALLING RECIPE already computes for its +// own probes, and re-deriving all three here is why adding a second such SDK +// meant editing the engine instead of publishing a package. +// +// So the recipe writes one file beside the payload and the engine reads it: +// +// /.mcpp-toolchain.json +// { +// "schema": 1, +// "frontend": "toolchains/llvm/prebuilt/linux-x86_64/bin/clang++", +// "platform_floor": "21", +// "std_module_defines": ["__BIONIC_CTYPE_INLINE="] +// } +// +// It is NOT a general flag channel. Three keys, each answering a question this +// engine already asks; a payload that could inject arbitrary flags would be a +// package changing a build it does not own, and `[build]` in a manifest is the +// project's to write. +struct PayloadDescriptor { + int schema = 0; + // Relative to the payload root, already host-resolved by the recipe. The + // engine stops computing a host tag; `frontendSubdir` stays as the answer + // for a payload that ships no descriptor. + std::string frontend; + // The string `llvm_triple(param)` already takes -- the payload's answer + // rather than a constant compiled in here. + std::string platformFloor; + // These reach the std module's own command assembly, which is a separate + // channel from the compile flags, and they enter the build fingerprint + // because they change what the module compiles to. + std::vector stdModuleDefines; +}; + +// The descriptor's file name, so a message and a test name the same string. +inline constexpr std::string_view payload_descriptor_filename = + ".mcpp-toolchain.json"; + +// Read `/.mcpp-toolchain.json`. +// +// THREE OUTCOMES, AND THE MIDDLE ONE IS THE REASON THIS RETURNS `expected`. +// +// nullopt no descriptor -- today's behaviour exactly, so a released +// payload keeps working and there is no flag day. +// a descriptor the payload answered. +// unexpected PRESENT AND MALFORMED. Refused, naming the file. Silently +// falling back would make a typo read as "an older payload" +// and the engine would use a hardcoded path for a layout +// that has moved -- which is the failure mode this +// repository records most often: the lookup is repaired and +// the message is not. +std::expected, std::string> +read_payload_descriptor(const std::filesystem::path& payloadRoot); + +// The descriptor for the payload a COMPILER belongs to. +// +// Two of the three answers are needed where only the compiler path is in +// hand, and the payload root is some number of directories above it -- five +// for the NDK, two for emsdk and for llvm. Found by walking up, not by +// counting components, because the count is exactly the kind of fact that +// changes silently when a layout does (`ndk_min_api_level` walks for the same +// reason). The walk is BOUNDED so that a compiler outside any payload cannot +// reach a descriptor belonging to a directory that is not its payload. +std::expected, std::string> +payload_descriptor_for_compiler(const std::filesystem::path& compilerPath); // The DIRECTORY `payload_frontend` searched, for a message that has to name it. // @@ -694,6 +770,125 @@ std::filesystem::path toolchain_frontend(const std::filesystem::path& binDir, return {}; } +std::expected, std::string> +read_payload_descriptor(const std::filesystem::path& payloadRoot) { + std::error_code ec; + auto file = payloadRoot / payload_descriptor_filename; + if (!std::filesystem::exists(file, ec)) return std::nullopt; + + // EVERY REFUSAL NAMES THE FILE. A message that says only "invalid + // descriptor" sends the reader to the documentation; one that names the + // path is one edit away from a fix, and the recipe that wrote it is the + // thing that has to change. + auto refuse = [&](std::string_view what) { + return std::unexpected(std::format("{}: {}", file.string(), what)); + }; + + std::ifstream in(file); + if (!in) return refuse("cannot be read"); + auto j = nlohmann::json::parse(in, nullptr, false); + if (j.is_discarded()) return refuse("is not valid JSON"); + if (!j.is_object()) return refuse("is not a JSON object"); + + PayloadDescriptor d; + if (!j.contains("schema") || !j["schema"].is_number_integer()) + return refuse("has no integer \"schema\""); + d.schema = j["schema"].get(); + // A SCHEMA THIS ENGINE DOES NOT IMPLEMENT IS REFUSED, NOT IGNORED. The + // descriptor exists because the built-in guess is wrong for this payload; + // a newer schema means the payload is describing something this engine + // cannot read, and falling back to the guess is the one outcome the + // descriptor was added to prevent. The obligation is therefore on the + // recipe: a schema bump comes with a floor on the mcpp version that reads + // it, the same way an index descriptor's syntax does. + if (d.schema != 1) + return refuse(std::format( + "declares schema {}, which this mcpp does not implement " + "(expected 1); upgrade mcpp to build with this payload", + d.schema)); + + if (j.contains("frontend")) { + if (!j["frontend"].is_string()) + return refuse("\"frontend\" is not a string"); + d.frontend = j["frontend"].get(); + if (d.frontend.empty()) return refuse("\"frontend\" is empty"); + // IT CANNOT LEAVE THE PAYLOAD. A descriptor naming `/usr/bin/g++` or + // `../../..` would make a package choose a host compiler, which is + // the one thing a payload must not be able to do: mcpp's hermeticity + // is a property of the payload boundary, not of the recipe's good + // manners. + std::filesystem::path rel(d.frontend); + if (rel.is_absolute()) + return refuse("\"frontend\" is absolute; it is relative to the " + "payload root"); + for (auto const& part : rel) + if (part == "..") + return refuse("\"frontend\" leaves the payload root"); + } + + if (j.contains("platform_floor")) { + // ONE SPELLING. `llvm_triple` takes the string, the manifest's + // `min_api_level` is a string, and a number here would be a second + // spelling for the same value -- which this engine has recorded as + // the shape that makes two mechanisms out of one question. The + // recipe's own tests assert this file's content, so the refusal is + // caught where the file is written rather than where it is read. + if (!j["platform_floor"].is_string()) + return refuse("\"platform_floor\" is not a string (write \"21\", " + "not 21)"); + d.platformFloor = j["platform_floor"].get(); + if (d.platformFloor.empty()) + return refuse("\"platform_floor\" is empty"); + if (d.platformFloor.find_first_not_of("0123456789.") != std::string::npos) + return refuse("\"platform_floor\" is not a version"); + } + + if (j.contains("std_module_defines")) { + if (!j["std_module_defines"].is_array()) + return refuse("\"std_module_defines\" is not an array"); + for (auto const& e : j["std_module_defines"]) { + if (!e.is_string()) + return refuse("\"std_module_defines\" holds a non-string"); + auto def = e.get(); + // THIS IS THE KEY THAT COULD BECOME A FLAG CHANNEL, so it is the + // key with a shape. A define starts with an identifier character; + // anything that could begin an option is refused, and whitespace + // is refused because a value needing quotes crosses two parsers + // and this engine has paid for that twice. + if (def.empty()) + return refuse("\"std_module_defines\" holds an empty entry"); + const char c0 = def.front(); + if (!(std::isalpha(static_cast(c0)) || c0 == '_')) + return refuse(std::format( + "\"std_module_defines\" entry '{}' is not a define name; " + "this is not a flag channel", def)); + if (def.find_first_of(" \t\n\r\"'") != std::string::npos) + return refuse(std::format( + "\"std_module_defines\" entry '{}' contains whitespace " + "or a quote", def)); + d.stdModuleDefines.push_back(std::move(def)); + } + } + return d; +} + +std::expected, std::string> +payload_descriptor_for_compiler(const std::filesystem::path& compilerPath) { + // Eight levels covers every payload layout this engine resolves -- + // `toolchains/llvm/prebuilt//bin/clang++` is the deepest at five -- + // with room for one that is deeper, and stops well short of a machine's + // root. + constexpr int kMaxLevels = 8; + std::error_code ec; + auto dir = compilerPath.parent_path(); + for (int i = 0; i < kMaxLevels && !dir.empty() && dir != dir.parent_path(); + ++i, dir = dir.parent_path()) { + if (std::filesystem::exists(dir / payload_descriptor_filename, ec)) + return read_payload_descriptor(dir); + } + return std::nullopt; +} + std::filesystem::path payload_frontend_dir(const std::filesystem::path& payloadRoot, const XimToolchainPackage& pkg) { if (pkg.family == Family::Msvc) { @@ -704,11 +899,32 @@ std::filesystem::path payload_frontend_dir(const std::filesystem::path& payloadR return inst->clPath.parent_path(); return payloadRoot / "VC" / "Tools" / "MSVC" / pkg.ximVersion; } + // The payload's own answer when it has one -- otherwise this message + // would name the directory the guess would have searched while the + // lookup searched another. + // + // A malformed descriptor is not refused here: this function exists to + // NAME A DIRECTORY IN A MESSAGE, and `payload_frontend` has already + // refused by the time any caller composes one. + if (auto d = read_payload_descriptor(payloadRoot); d && *d && !(*d)->frontend.empty()) + return (payloadRoot / (*d)->frontend).parent_path(); return payloadRoot / pkg.frontendSubdir; } -std::filesystem::path payload_frontend(const std::filesystem::path& payloadRoot, - const XimToolchainPackage& pkg) { +std::expected +payload_frontend(const std::filesystem::path& payloadRoot, + const XimToolchainPackage& pkg) { + // THE PAYLOAD IS ASKED FIRST, AND ITS ANSWER IS NOT SECOND-GUESSED. If a + // descriptor names a frontend that is not there, the result is empty -- + // "nothing found where we looked" -- and the message names the directory + // the descriptor pointed at, because that is where the search happened. + auto desc = read_payload_descriptor(payloadRoot); + if (!desc) return std::unexpected(desc.error()); + if (*desc && !(*desc)->frontend.empty()) { + auto named = payloadRoot / (*desc)->frontend; + if (std::filesystem::exists(named)) return named; + return std::filesystem::path{}; + } if (pkg.family == Family::Msvc) { // Same resolution the install and build paths use, so the three // cannot disagree about where an msvc payload keeps its compiler. @@ -716,7 +932,7 @@ std::filesystem::path payload_frontend(const std::filesystem::path& payloadRoot, if (auto inst = mcpp::toolchain::msvc::installation_at(payloadRoot, pkg.ximVersion)) return inst->clPath; - return {}; + return std::filesystem::path{}; } return toolchain_frontend(payloadRoot / pkg.frontendSubdir, pkg); } diff --git a/tests/unit/test_platform_apple_sdk.cpp b/tests/unit/test_platform_apple_sdk.cpp new file mode 100644 index 00000000..f9f97b9c --- /dev/null +++ b/tests/unit/test_platform_apple_sdk.cpp @@ -0,0 +1,117 @@ +#include + +import std; +import mcpp.platform; +import mcpp.platform.macos; + +namespace mac = mcpp::platform::macos; + +// ─── The three Apple SDKs are a table, not three code paths (C1) ─────────── +// +// `sdk_path()` located exactly one SDK, because macOS was the only Apple +// target. The iOS rows need two more, and the difference between them is +// DIRECTORY NAMES -- so the change is a parameter over a table rather than a +// second locator. These two functions are the whole of what can be wrong in +// it, and they are pure, which is why a Linux runner can check them: the +// probing itself needs the machine. + +TEST(AppleSdk, EveryRequestedSdkNamesItsOwnDirectories) { + struct Case { std::string_view sdk, platformDir, sdkDir; bool clt; }; + const Case cases[] = { + { mac::sdk_macos, "MacOSX.platform", "MacOSX.sdk", true }, + { mac::sdk_iphoneos, "iPhoneOS.platform", "iPhoneOS.sdk", false }, + { mac::sdk_iphonesim, "iPhoneSimulator.platform", "iPhoneSimulator.sdk", false }, + }; + for (auto const& c : cases) { + auto l = mac::sdk_layout(c.sdk); + ASSERT_TRUE(l.has_value()) << c.sdk; + EXPECT_EQ(l->platformDir, c.platformDir) << c.sdk; + EXPECT_EQ(l->sdkDir, c.sdkDir) << c.sdk; + // A CLT-ONLY INSTALL SHIPS NO iOS SDK. Probing its SDKs directory for + // one would be a path that cannot exist, and the refusal that names + // the SDK is the better answer -- so the table says so. + EXPECT_EQ(l->inCommandLineTools, c.clt) << c.sdk; + } + // A name no Apple SDK answers to resolves to nothing, and `sdk_path` + // returns nullopt before it runs a single command. + for (auto bogus : {"", "ios", "iphone", "macos", "watchos"}) { + EXPECT_FALSE(mac::sdk_layout(bogus).has_value()) << bogus; + EXPECT_FALSE(mac::sdk_path(bogus).has_value()) << bogus; + } +} + +// SDKROOT NAMES ONE SDK AND THERE ARE THREE QUESTIONS. Honouring the override +// for every request would answer `sdk_path("iphoneos")` with a macOS SDK on +// any machine whose shell has SDKROOT set -- a wrong sysroot, which surfaces +// much later as missing headers rather than as a bad override. So the override +// is matched against what the path IS, and this is that comparison. +TEST(AppleSdk, AnOverrideAnswersOnlyForTheSdkItIs) { + struct Case { std::string_view path, name; }; + const Case cases[] = { + { "/x/MacOSX.sdk", mac::sdk_macos }, + { "/x/MacOSX15.4.sdk", mac::sdk_macos }, + { "/x/macosx.sdk", mac::sdk_macos }, + { "/x/iPhoneOS.sdk", mac::sdk_iphoneos }, + { "/x/iPhoneOS18.4.sdk", mac::sdk_iphoneos }, + { "/x/iPhoneSimulator18.4.sdk", mac::sdk_iphonesim }, + }; + for (auto const& c : cases) + EXPECT_EQ(mac::sdk_name_of_root(std::filesystem::path(c.path)), c.name) + << c.path; + + // AND THE TWO iOS NAMES DO NOT COLLAPSE INTO EACH OTHER. Both begin + // `iPhone`, and a prefix test would make the simulator SDK satisfy a + // device request -- an SDK that compiles and produces an artefact for the + // wrong platform, which no later step refuses. + EXPECT_NE(mac::sdk_name_of_root("/x/iPhoneSimulator18.4.sdk"), + mac::sdk_name_of_root("/x/iPhoneOS18.4.sdk")); + + // AND `.sdk` IS REQUIRED. This is positive identification, and the suffix + // is the marker Apple's own naming gives an SDK; `iPhoneOS.platform` is + // the directory that CONTAINS one, and a bare `MacOSX` names nothing. + for (auto notAnSdk : {"/x/Developer", "/x/MacOSX", "/x/iPhoneOS", + "/x/iPhoneOS.platform", "/x/.sdk", + "/x/Frameworks.sdk"}) + EXPECT_TRUE(mac::sdk_name_of_root(notAnSdk).empty()) << notAnSdk; +} + +// The override arm itself, which on macOS sits behind a filesystem probe and +// is therefore reachable by no other check on a Linux runner. +TEST(AppleSdk, AnUnnamedOverrideAnswersTheDefaultQuestionOnly) { + // Positive identification: an SDK answers for itself and for nothing else. + EXPECT_TRUE (mac::sdkroot_answers("/x/iPhoneOS18.4.sdk", mac::sdk_iphoneos)); + EXPECT_FALSE(mac::sdkroot_answers("/x/iPhoneOS18.4.sdk", mac::sdk_iphonesim)); + EXPECT_FALSE(mac::sdkroot_answers("/x/iPhoneOS18.4.sdk", mac::sdk_macos)); + EXPECT_TRUE (mac::sdkroot_answers("/x/MacOSX15.4.sdk", mac::sdk_macos)); + EXPECT_FALSE(mac::sdkroot_answers("/x/MacOSX15.4.sdk", mac::sdk_iphoneos)); + + // NO FLAG DAY. `SDKROOT=/opt/my-sysroot` is a spelling clang accepts and + // a spelling `sdk_path()` honoured before it took a parameter, so it + // still answers the default question -- and still does not answer an iOS + // one, because a hand-rolled macOS sysroot is not an iOS SDK. + for (auto unnamed : {"/opt/my-sysroot", "/x/Developer", "/x/MacOSX"}) { + EXPECT_TRUE (mac::sdkroot_answers(unnamed, mac::sdk_macos)) << unnamed; + EXPECT_FALSE(mac::sdkroot_answers(unnamed, mac::sdk_iphoneos)) << unnamed; + EXPECT_FALSE(mac::sdkroot_answers(unnamed, mac::sdk_iphonesim)) << unnamed; + } + + // A name no Apple SDK answers to is not a question, so no override + // answers it. + EXPECT_FALSE(mac::sdkroot_answers("/x/MacOSX15.4.sdk", "watchos")); +} + +// NO FLAG DAY FOR THE THREE CALLERS. The parameter has a default, and the +// default request behaves exactly as the unparameterised function did: on a +// non-Apple host every form returns nullopt, and on macOS the generic +// `xcrun --show-sdk-path` probe stays first for it and is not used for any +// other SDK, because it returns the ACTIVE default rather than the one asked +// for. +TEST(AppleSdk, TheDefaultRequestIsTheMacosSdk) { + EXPECT_EQ(mac::sdk_path(), mac::sdk_path(mac::sdk_macos)); + if constexpr (!mcpp::platform::is_macos) { + EXPECT_FALSE(mac::sdk_path().has_value()) + << "an Apple SDK on a host that has none is a located path that " + "does not exist"; + EXPECT_FALSE(mac::sdk_path(mac::sdk_iphoneos).has_value()); + } +} diff --git a/tests/unit/test_toolchain_descriptor.cpp b/tests/unit/test_toolchain_descriptor.cpp new file mode 100644 index 00000000..bf9b8dca --- /dev/null +++ b/tests/unit/test_toolchain_descriptor.cpp @@ -0,0 +1,239 @@ +#include + +import std; +import mcpp.platform; +import mcpp.toolchain.model; +import mcpp.toolchain.registry; +import mcpp.toolchain.triple; + +using namespace mcpp::toolchain; + +namespace { + +// A payload root in a temporary directory, with an optional descriptor and an +// optional compiler at a chosen relative path. Nothing here is Android- or +// emsdk-specific: the contract is that A PAYLOAD DESCRIBES ITSELF, and a test +// tied to one payload's layout would be testing the layout. +struct FakePayload { + std::filesystem::path root; + explicit FakePayload(std::string_view name) { + root = std::filesystem::temp_directory_path() + / std::format("mcpp-desc-{}-{}", name, + std::chrono::steady_clock::now() + .time_since_epoch().count()); + std::filesystem::create_directories(root); + } + ~FakePayload() { std::error_code ec; std::filesystem::remove_all(root, ec); } + FakePayload(const FakePayload&) = delete; + FakePayload& operator=(const FakePayload&) = delete; + + void write_descriptor(std::string_view json) const { + std::ofstream(root / ".mcpp-toolchain.json") << json; + } + std::filesystem::path add_compiler(std::string_view rel) const { + auto p = root / rel; + std::filesystem::create_directories(p.parent_path()); + std::ofstream(p) << "#!/bin/sh\n"; + return p; + } +}; + +XimToolchainPackage bin_shaped_pkg() { + // The built-in guess for a payload that ships no descriptor: `bin/` and a + // clang-shaped frontend. This is what "no flag day" means, expressed as an + // object rather than as a sentence. + auto spec = parse_toolchain_spec("llvm@22.1.8"); + return to_xim_package(*spec); +} + +} // namespace + +// ─── Absence is compatibility (C) ─────────────────────────────────────────── +// +// A released payload ships no descriptor, and there is no version of this +// feature in which that stops working. The criterion has a denominator: the +// SAME package object resolves the SAME frontend as before the descriptor +// existed, which is what the assertion below measures. +TEST(PayloadDescriptor, NoDescriptorResolvesExactlyAsBefore) { + FakePayload fp{"none"}; + auto clang = fp.add_compiler("bin/clang++"); + auto pkg = bin_shaped_pkg(); + + auto d = read_payload_descriptor(fp.root); + ASSERT_TRUE(d.has_value()) << d.error(); + EXPECT_FALSE(d->has_value()) << "no file means no answer, not an error"; + + auto found = payload_frontend(fp.root, pkg); + ASSERT_TRUE(found.has_value()) << found.error(); + EXPECT_EQ(*found, clang); + EXPECT_EQ(payload_frontend_dir(fp.root, pkg), fp.root / "bin"); +} + +// ─── The engine stops knowing the layout (C) ──────────────────────────────── +// +// The criterion is that the guess is NOT CONSULTED when the payload answers, +// and the only way to assert that is a payload whose compiler is NOT where the +// guess would look. Here `bin/` is empty and the compiler is five levels down +// in the NDK's own shape -- so a resolution that succeeds could only have come +// from the descriptor. +TEST(PayloadDescriptor, TheDescriptorAnswersWhereTheGuessCannot) { + FakePayload fp{"frontend"}; + auto deep = fp.add_compiler( + "toolchains/llvm/prebuilt/some-host-tag/bin/clang++"); + std::filesystem::create_directories(fp.root / "bin"); // present, empty + fp.write_descriptor(R"({ + "schema": 1, + "frontend": "toolchains/llvm/prebuilt/some-host-tag/bin/clang++" + })"); + auto pkg = bin_shaped_pkg(); + + // `some-host-tag` is not a host tag this engine can compute, which is the + // point: the path is the payload's, not a derivation. + auto found = payload_frontend(fp.root, pkg); + ASSERT_TRUE(found.has_value()) << found.error(); + EXPECT_EQ(*found, deep); + + // AND THE MESSAGE NAMES WHERE THE SEARCH HAPPENED. A refusal naming + // `/bin` while the lookup read the descriptor's path is the failure + // this repository records most often: the lookup is repaired and the + // message is not. + EXPECT_EQ(payload_frontend_dir(fp.root, pkg), deep.parent_path()); +} + +// A descriptor naming a frontend that is not there is "nothing found where we +// looked" -- empty, the caller's long-standing cue -- and NOT an error. The +// two outcomes have different repairs: install the payload again, versus fix +// the recipe. +TEST(PayloadDescriptor, ANamedFrontendThatIsAbsentIsEmptyNotAnError) { + FakePayload fp{"absent"}; + fp.add_compiler("bin/clang++"); // the guess WOULD find this + fp.write_descriptor(R"({"schema": 1, "frontend": "opt/bin/clang++"})"); + + auto found = payload_frontend(fp.root, bin_shaped_pkg()); + ASSERT_TRUE(found.has_value()) << found.error(); + EXPECT_TRUE(found->empty()) + << "the payload said where its compiler is; the guess is not a " + "second opinion"; +} + +// ─── Present and malformed is refused, naming the file (C) ────────────────── +// +// This is the property that makes the descriptor safe to add. Without it a typo +// reads as "an older payload": the engine falls back to a hardcoded path for a +// layout that has moved, and the build fails somewhere else entirely. +TEST(PayloadDescriptor, EveryMalformedShapeIsRefusedByName) { + struct Case { std::string_view why, json, mustMention; }; + const Case cases[] = { + { "not JSON", "{ not json", "JSON" }, + { "not an object", "[1, 2, 3]", "object" }, + { "no schema", R"({"frontend": "bin/clang++"})", "schema" }, + { "schema not an int", R"({"schema": "1"})", "schema" }, + { "a schema we lack", R"({"schema": 2})", "schema 2" }, + { "frontend not string", R"({"schema":1,"frontend":42})", "frontend" }, + { "frontend empty", R"({"schema":1,"frontend":""})", "frontend" }, + { "frontend absolute", R"({"schema":1,"frontend":"/usr/bin/g++"})", + "absolute" }, + { "frontend escapes", R"({"schema":1,"frontend":"../../usr/bin/g++"})", + "leaves" }, + { "floor not a string", R"({"schema":1,"platform_floor":21})", "platform_floor" }, + { "floor empty", R"({"schema":1,"platform_floor":""})", "platform_floor" }, + { "floor not a version", R"({"schema":1,"platform_floor":"r30"})", + "platform_floor" }, + { "defines not an array", R"({"schema":1,"std_module_defines":"A="})", + "std_module_defines" }, + { "defines hold an int", R"({"schema":1,"std_module_defines":[1]})", + "non-string" }, + { "a define is a flag", R"({"schema":1,"std_module_defines":["-Wl,-rpath,/usr/lib"]})", + "flag channel" }, + { "a define has a space", R"({"schema":1,"std_module_defines":["A=b c"]})", + "whitespace" }, + }; + for (auto const& c : cases) { + FakePayload fp{"bad"}; + fp.add_compiler("bin/clang++"); + fp.write_descriptor(c.json); + + auto d = read_payload_descriptor(fp.root); + ASSERT_FALSE(d.has_value()) << c.why << " was accepted"; + // THE FILE IS NAMED IN EVERY ONE. A message that says only "invalid + // descriptor" leaves the reader to find which payload wrote it. + EXPECT_NE(d.error().find(".mcpp-toolchain.json"), std::string::npos) + << c.why << ": " << d.error(); + EXPECT_NE(d.error().find(c.mustMention), std::string::npos) + << c.why << ": " << d.error(); + + // And the refusal travels: the frontend lookup cannot express it as + // "not found", so it propagates rather than falling back to the guess + // -- even though the guess would have succeeded here. + auto found = payload_frontend(fp.root, bin_shaped_pkg()); + ASSERT_FALSE(found.has_value()) << c.why << " fell back to the guess"; + EXPECT_EQ(found.error(), d.error()) << c.why; + } +} + +// ─── The three answers, read as written ───────────────────────────────────── +TEST(PayloadDescriptor, TheThreeKeysAreCarriedThrough) { + FakePayload fp{"full"}; + fp.add_compiler("toolchains/llvm/prebuilt/linux-x86_64/bin/clang++"); + fp.write_descriptor(R"({ + "schema": 1, + "frontend": "toolchains/llvm/prebuilt/linux-x86_64/bin/clang++", + "platform_floor": "21", + "std_module_defines": ["__BIONIC_CTYPE_INLINE=", "_FORTIFY_SOURCE=0"] + })"); + auto d = read_payload_descriptor(fp.root); + ASSERT_TRUE(d.has_value()) << d.error(); + ASSERT_TRUE(d->has_value()); + EXPECT_EQ((*d)->schema, 1); + EXPECT_EQ((*d)->frontend, + "toolchains/llvm/prebuilt/linux-x86_64/bin/clang++"); + EXPECT_EQ((*d)->platformFloor, "21"); + ASSERT_EQ((*d)->stdModuleDefines.size(), 2u); + EXPECT_EQ((*d)->stdModuleDefines[0], "__BIONIC_CTYPE_INLINE="); + EXPECT_EQ((*d)->stdModuleDefines[1], "_FORTIFY_SOURCE=0"); + + // ALL THREE KEYS ARE OPTIONAL. A payload that answers one question and + // not the others is a payload, not a malformed file -- otherwise a second + // SDK would have to fabricate answers it does not have. + fp.write_descriptor(R"({"schema": 1})"); + auto bare = read_payload_descriptor(fp.root); + ASSERT_TRUE(bare.has_value()) << bare.error(); + ASSERT_TRUE(bare->has_value()); + EXPECT_TRUE((*bare)->frontend.empty()); + EXPECT_TRUE((*bare)->platformFloor.empty()); + EXPECT_TRUE((*bare)->stdModuleDefines.empty()); +} + +// ─── Found from the compiler, by walking rather than counting ─────────────── +// +// Two of the three answers are consumed where only the compiler path is in +// hand. Counting components is the fact that changes silently when a layout +// does, so the search walks up -- and is bounded, so a compiler that belongs +// to no payload cannot adopt a descriptor from some ancestor directory. +TEST(PayloadDescriptor, FoundFromTheCompilerAndTheWalkIsBounded) { + FakePayload fp{"walk"}; + auto deep = fp.add_compiler( + "toolchains/llvm/prebuilt/linux-x86_64/bin/clang++"); + fp.write_descriptor(R"({"schema": 1, "platform_floor": "21"})"); + + auto d = payload_descriptor_for_compiler(deep); + ASSERT_TRUE(d.has_value()) << d.error(); + ASSERT_TRUE(d->has_value()) << "five levels up was not reached"; + EXPECT_EQ((*d)->platformFloor, "21"); + + // A refusal still travels through the walk, because a compiler whose + // payload describes itself wrongly is the same defect wherever it is read. + fp.write_descriptor(R"({"schema": 1, "platform_floor": 21})"); + auto bad = payload_descriptor_for_compiler(deep); + ASSERT_FALSE(bad.has_value()); + EXPECT_NE(bad.error().find(".mcpp-toolchain.json"), std::string::npos) + << bad.error(); + + // Beyond the bound there is no answer. Nine levels is one past it. + auto far = fp.add_compiler("a/b/c/d/e/f/g/h/i/clang++"); + auto none = payload_descriptor_for_compiler(far); + ASSERT_TRUE(none.has_value()) << none.error(); + EXPECT_FALSE(none->has_value()) + << "an unbounded walk adopts a descriptor from a directory that is " + "not this payload"; +} diff --git a/tests/unit/test_toolchain_msvc.cpp b/tests/unit/test_toolchain_msvc.cpp index b6ca5a85..1b7113ae 100644 --- a/tests/unit/test_toolchain_msvc.cpp +++ b/tests/unit/test_toolchain_msvc.cpp @@ -308,8 +308,10 @@ TEST(MsvcManaged, PayloadFrontendFindsClWhereMsvcActuallyKeepsIt) { // `frontendSubdir` at all. EXPECT_EQ(pkg.family, Family::Msvc); auto found = payload_frontend(t.root, pkg); - ASSERT_FALSE(found.empty()) << "payload_frontend found no cl.exe under " << t.root; - EXPECT_EQ(found.filename(), "cl.exe"); + ASSERT_TRUE(found.has_value()) << found.error(); + ASSERT_FALSE(found->empty()) + << "payload_frontend found no cl.exe under " << t.root; + EXPECT_EQ(found->filename(), "cl.exe"); // The `bin/`-shaped question is the one that used to be asked, and it // still answers nothing here — which is exactly why it was the wrong @@ -320,7 +322,7 @@ TEST(MsvcManaged, PayloadFrontendFindsClWhereMsvcActuallyKeepsIt) { // returning a path that does not exist. EXPECT_TRUE(payload_frontend(t.root, to_xim_package(*parse_toolchain_spec("msvc@14.52.36629"))) - .empty()); + .value().empty()); // AND THE DIRECTORY THE SEARCH USED IS AVAILABLE FOR THE MESSAGE. MSVC is // the case that proves it is a lookup rather than a constant: the path diff --git a/tests/unit/test_toolchain_registry.cpp b/tests/unit/test_toolchain_registry.cpp index 512766d0..646ef933 100644 --- a/tests/unit/test_toolchain_registry.cpp +++ b/tests/unit/test_toolchain_registry.cpp @@ -492,3 +492,56 @@ TEST(SdkPayloads, TheDisplayNamesThePayloadAndNotOnlyTheFamily) { EXPECT_NE(sp->display().find(spelled), std::string::npos) << sp->display(); } } + +// ─── One payload, one spelling (B1) ──────────────────────────────────────── +// +// `ndk` was accepted as an alias for `android-ndk`, and the capability gate +// refused it -- because that gate compares the DECLARED SPELLING against the +// row's pin (`android-ndk@30.0.16248370`), and `ndk@30.0.16248370` does not +// contain `android-ndk`. So the alias parsed and was then rejected at the +// point of use, which a reader sees as a defect rather than as a naming +// choice: the name was good enough for the parser and not for the build. +// +// Withdrawn rather than completed. Normalising payload names inside the gate +// would make two spellings work and would put the comparison in a second +// mechanism; one name keeps the gate correct by construction. The name kept is +// the one the index uses, so a single string names this payload ecosystem-wide. +// +// The criterion is WHERE the refusal happens, not that one happens. +TEST(SdkPayloads, TheWithdrawnAliasIsRefusedAtParseAndNotByTheCapabilityGate) { + auto aliased = mcpp::toolchain::parse_toolchain_spec("ndk@30.0.16248370"); + ASSERT_FALSE(aliased.has_value()) + << "an alias that parses is an alias the capability gate must refuse " + "later, by comparing spellings"; + EXPECT_NE(aliased.error().find("unknown toolchain"), std::string::npos) + << aliased.error(); + EXPECT_NE(aliased.error().find("'ndk'"), std::string::npos) + << "the refusal quotes what was typed: " << aliased.error(); + // AND IT SAYS WHICH SPELLING TO USE. A refusal that names the accepted + // payload is one edit away from a build; one that does not sends the + // reader to the documentation. + EXPECT_NE(aliased.error().find("android-ndk"), std::string::npos) + << aliased.error(); + + // The kept spelling is unaffected, and it is the one the row pins. + auto kept = mcpp::toolchain::parse_toolchain_spec("android-ndk@30.0.16248370"); + ASSERT_TRUE(kept.has_value()) << kept.error(); + EXPECT_EQ(kept->payloadName, "android-ndk"); + + // THE MECHANISM THAT NO LONGER HAS TO BE TAUGHT. Both Android rows pin the + // kept spelling, and the gate's test is `pin's name is a substring of what + // was declared` -- true for the kept spelling, false for the alias. With + // the alias gone, the gate never sees the case it would have to special. + for (auto triple : {"aarch64-linux-android", "x86_64-linux-android"}) { + auto parsed = mcpp::toolchain::triple::parse(triple); + ASSERT_TRUE(parsed.has_value()) << triple; + auto info = mcpp::toolchain::triple::find_known_target(*parsed); + ASSERT_NE(info, nullptr) << triple; + const std::string pin(info->pin); + const std::string pinName = pin.substr(0, pin.find('@')); + EXPECT_EQ(pinName, "android-ndk") << triple << " pins " << pin; + EXPECT_EQ(std::string("ndk@30.0.16248370").find(pinName), + std::string::npos) + << "the alias could only pass the gate by a second mechanism"; + } +} diff --git a/tests/unit/test_toolchain_triple.cpp b/tests/unit/test_toolchain_triple.cpp index c4157807..077365e8 100644 --- a/tests/unit/test_toolchain_triple.cpp +++ b/tests/unit/test_toolchain_triple.cpp @@ -204,11 +204,12 @@ TEST(Triple, RejectsNonTriples) { // ── known-target vocabulary ────────────────────────────────────────────────── TEST(Triple, KnownTargetTableExposesTierAndPins) { - auto t = parse("x86_64-linux-musl"); + auto [name, tier] = std::pair{"x86_64-linux-musl", "verified"}; + auto t = parse(name); ASSERT_TRUE(t.has_value()); auto* info = find_known_target(*t); ASSERT_NE(info, nullptr); - EXPECT_EQ(info->tier, "verified"); + EXPECT_EQ(info->tier, tier); EXPECT_EQ(info->pin, "gcc@16.1.0"); EXPECT_TRUE(info->defaultStatic); @@ -676,11 +677,12 @@ TEST(Triple, EachRowsTierMatchesTheEvidenceThatExistsForIt) { // Measured 2026-09-11 on linux-x86_64 with xim:emsdk 6.0.9: // `mcpp run --target wasm32-emscripten` on a source that imports std // printed `1-2-3`. Built and run, so `verified`. - auto t = parse("wasm32-emscripten"); + auto [name, tier] = std::pair{"wasm32-emscripten", "verified"}; + auto t = parse(name); ASSERT_TRUE(t.has_value()); auto* info = find_known_target(*t); ASSERT_NE(info, nullptr); - EXPECT_EQ(info->tier, "verified"); + EXPECT_EQ(info->tier, tier); // A ROW THAT IS WIRED NAMES ITS PAYLOAD. Without the pin the row's // tier was reachable only through an explicit // `[target.wasm32-emscripten] toolchain = "..."` override, which is @@ -707,13 +709,21 @@ TEST(Triple, EachRowsTierMatchesTheEvidenceThatExistsForIt) { // ONE PIN SERVES BOTH ROWS, which is the property the whole Android path // rests on: the NDK names no arch, `--target` does, and that is why the // std module's own precompile had to be told the target as well. - // ONE PIN, TWO TIERS, and the tiers differ by EXECUTION rather than by - // confidence in the build. `x86_64-linux-android` ran on the platform's - // own emulator (API 24 x86_64 image, KVM): `adb push` then - // `adb shell ./andtest` printed `1-2-3`, exit 0. The device row has no - // execution path from an x86_64 host -- Google's emulator refuses a - // foreign guest outright -- so it stays `preview`. - for (auto [name, tier] : {std::pair{"aarch64-linux-android", "preview"}, + // ONE PIN, TWO ROWS, BOTH VERIFIED -- BY DIFFERENT VEHICLES. + // `x86_64-linux-android` ran on the platform's own emulator (API 24 + // x86_64 image, KVM): `adb push` then `adb shell ./andtest` printed + // `1-2-3`, exit 0. The device row has no such path from an x86_64 host -- + // Google's emulator refuses a foreign guest outright -- and that bounded + // the EMULATOR, not the row: `7zz x ` with the packaged 7zip + // extracts bionic, and `qemu-aarch64-static -L ` printed + // `1-2-3`, exit 0. + // + // AND THIS ASSERTION WAS THE FIFTH COPY OF THE TIER. The four documents + // are compared to this table by `.github/tools/check_target_tiers.py`; + // this line is in neither set, so it went on asserting `preview` after + // the row and all four documents had moved. A structural check over the + // documents cannot see a literal in a test. + for (auto [name, tier] : {std::pair{"aarch64-linux-android", "verified"}, std::pair{"x86_64-linux-android", "verified"}}) { auto t = parse(name); ASSERT_TRUE(t.has_value()) << name; @@ -731,14 +741,15 @@ TEST(Triple, EachRowsTierMatchesTheEvidenceThatExistsForIt) { // iPhoneSimulator SDKs ship inside Xcode and are neither. No amount of // engine work moves these, which is why they carry no pin: there is // nothing for a pin to name. - for (auto name : {"aarch64-ios", "aarch64-ios-sim", "x86_64-ios-sim"}) { + for (auto [name, tier] : {std::pair{"aarch64-ios", "planned"}, + std::pair{"aarch64-ios-sim", "planned"}, + std::pair{"x86_64-ios-sim", "planned"}}) { auto t = parse(name); ASSERT_TRUE(t.has_value()) << name; EXPECT_EQ(t->str(), name); auto* info = find_known_target(*t); ASSERT_NE(info, nullptr) << name; - EXPECT_EQ(info->tier, "planned") << name; - EXPECT_TRUE(info->pin.empty()) << name; + EXPECT_EQ(info->tier, tier) << name; EXPECT_TRUE(info->sysroot.empty()) << name; } } From 3e781ca18324938c5db96159a0a5aebe20a7c432 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Fri, 11 Sep 2026 19:01:50 +0800 Subject: [PATCH 04/14] 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 `, 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. --- .github/workflows/ci-macos-ios.yml | 180 ++++++++++++++++++++++-- modules/toolchain-model/src/model.cppm | 24 ++++ modules/toolchain-model/src/triple.cppm | 27 +++- src/build/distribution.cppm | 44 ++++++ src/build/flags.cppm | 81 ++++++++++- src/build/prepare.cppm | 61 ++++++++ src/toolchain/hostflags.cppm | 42 +++++- tests/unit/test_toolchain_triple.cpp | 20 ++- 8 files changed, 457 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci-macos-ios.yml b/.github/workflows/ci-macos-ios.yml index aca0aa34..056ace14 100644 --- a/.github/workflows/ci-macos-ios.yml +++ b/.github/workflows/ci-macos-ios.yml @@ -39,7 +39,14 @@ jobs: # the device row is still verifiable here and the two simulator rows # become a local-only claim -- which changes the tier they can reach and # nothing else. This step is the difference between those two worlds. + # EVERY MEASUREMENT STEP CONTINUES, because the first version of this job + # did not and the four steps after the failing one were SKIPPED -- so one + # unmet assumption cost the whole measurement. A probe's value is the + # complete set of answers; a probe that stops at the first `no` is a + # build, not a measurement. (GitHub runs `run:` under `bash -e`, which is + # what turned a failing clang into a terminated step.) - name: "Host surface: the two located SDKs and the simulator runtime" + continue-on-error: true run: | set -x xcode-select -p @@ -59,7 +66,30 @@ jobs: # `simctl-run` program is a boot-and-spawn wrapper; if it does not, it # has to synthesise a bundle, sign it and install it. Measuring this is # cheaper than designing for the harder case. + # THE PAYLOAD'S CLANG READS A CONFIG FILE, AND THAT CONFIG NAMES A + # DIFFERENT SDK. First run, with `-isysroot ` + # on the command line: + # + # ld64.lld: error: /Library/Developer/CommandLineTools/SDKs/ + # MacOSX.sdk/usr/lib/libc++.tbd(/usr/lib/libc++.1.dylib) is + # incompatible with arm64 (iOS Simulator18.0.0) + # + # mcpp's own post-install writes the located macOS SDK into the + # payload's `clang++.cfg` so that a native macOS build is deterministic, + # and that cfg is read before the command line for search purposes. So + # an Apple CROSS target has to suppress it -- which mcpp's cross path + # already does (`--no-default-config` in hostflags.cppm), and which this + # hand-written probe did not. Printed here because the explanation + # belongs next to the measurement. + - name: "The payload's default config, which is why --no-default-config" + continue-on-error: true + run: | + set -x + ls -la "$LLVM_ROOT/bin/"*.cfg || true + for f in "$LLVM_ROOT/bin/"*.cfg; do echo "=== $f"; cat "$f"; done || true + - name: "Device: is one bootable, and does spawn take a bare executable" + continue-on-error: true run: | set -uo pipefail UDID=$(xcrun simctl list devices available \ @@ -71,15 +101,18 @@ jobs: exit 0 fi xcrun simctl boot "$UDID" || true - xcrun simctl bootstatus "$UDID" -b || true + xcrun simctl bootstatus "$UDID" -b 2>&1 | tail -3 || true cat > /tmp/hello.cpp << 'CPP' #include int main() { std::puts("1-2-3"); return 0; } CPP SDK=$(xcrun --sdk iphonesimulator --show-sdk-path) - "$LLVM_ROOT/bin/clang++" -std=c++23 \ - -target arm64-apple-ios18.0-simulator \ - -isysroot "$SDK" -o /tmp/hello /tmp/hello.cpp + if ! "$LLVM_ROOT/bin/clang++" -std=c++23 --no-default-config \ + -target arm64-apple-ios18.0-simulator \ + -isysroot "$SDK" -o /tmp/hello /tmp/hello.cpp; then + echo "SIM-COMPILE-FAILED" + exit 0 + fi file /tmp/hello otool -l /tmp/hello | grep -A5 LC_BUILD_VERSION || true echo "--- simctl spawn on a bare Mach-O ---" @@ -95,6 +128,7 @@ jobs: # that says MACOS there is the failure this leg exists to catch, and it # is invisible to a build that merely succeeds. - name: "Device: the artefact names the iOS platform" + continue-on-error: true run: | set -euo pipefail cat > /tmp/dev.cpp << 'CPP' @@ -102,7 +136,8 @@ jobs: int main() { std::puts("1-2-3"); return 0; } CPP SDK=$(xcrun --sdk iphoneos --show-sdk-path) - "$LLVM_ROOT/bin/clang++" -std=c++23 -target arm64-apple-ios18.0 \ + "$LLVM_ROOT/bin/clang++" -std=c++23 --no-default-config \ + -target arm64-apple-ios18.0 \ -isysroot "$SDK" -o /tmp/dev /tmp/dev.cpp file /tmp/dev otool -l /tmp/dev | grep -A5 LC_BUILD_VERSION @@ -115,6 +150,7 @@ jobs: # works" is the fact the flag builder needs and neither answer can be # reasoned out from a Linux desk. - name: "C++ runtime: SDK libc++ versus the payload static archive" + continue-on-error: true run: | set -x SDK=$(xcrun --sdk iphoneos --show-sdk-path) @@ -124,7 +160,8 @@ jobs: int main() { std::string s = "1-2-3"; std::puts(s.c_str()); return 0; } CPP echo "--- (a) SDK libc++, dynamic ---" - if "$LLVM_ROOT/bin/clang++" -std=c++23 -target arm64-apple-ios18.0 \ + if "$LLVM_ROOT/bin/clang++" -std=c++23 --no-default-config \ + -target arm64-apple-ios18.0 \ -isysroot "$SDK" -o /tmp/cxx-sdk /tmp/cxx.cpp; then otool -L /tmp/cxx-sdk echo "SDK-LIBCXX-OK" @@ -132,7 +169,8 @@ jobs: echo "SDK-LIBCXX-FAILED" fi echo "--- (b) payload static libc++ ---" - if "$LLVM_ROOT/bin/clang++" -std=c++23 -target arm64-apple-ios18.0 \ + if "$LLVM_ROOT/bin/clang++" -std=c++23 --no-default-config \ + -target arm64-apple-ios18.0 \ -isysroot "$SDK" -nostdlib++ \ "$LLVM_ROOT/lib/libc++.a" "$LLVM_ROOT/lib/libc++abi.a" \ -o /tmp/cxx-static /tmp/cxx.cpp; then @@ -148,13 +186,15 @@ jobs: # uses, with a second SDK. If this cannot be made to work the iOS rows # are a non-module tier and the documentation has to say so. - name: "import std: precompile against the located SDK" + continue-on-error: true run: | set -x SDK=$(xcrun --sdk iphoneos --show-sdk-path) STD_CPPM=$(find "$LLVM_ROOT" -name 'std.cppm' | head -1) echo "std.cppm=$STD_CPPM" MODDIR=$(dirname "$STD_CPPM") - if "$LLVM_ROOT/bin/clang++" -std=c++23 -target arm64-apple-ios18.0 \ + if "$LLVM_ROOT/bin/clang++" -std=c++23 --no-default-config \ + -target arm64-apple-ios18.0 \ -isysroot "$SDK" -Wno-reserved-module-identifier \ -Xclang -emit-reduced-module-interface \ --precompile -o /tmp/std.pcm "$STD_CPPM" -I"$MODDIR"; then @@ -166,7 +206,8 @@ jobs: import std; int main() { std::println("1-2-3"); return 0; } CPP - if "$LLVM_ROOT/bin/clang++" -std=c++23 -target arm64-apple-ios18.0 \ + if "$LLVM_ROOT/bin/clang++" -std=c++23 --no-default-config \ + -target arm64-apple-ios18.0 \ -isysroot "$SDK" -fmodule-file=std=/tmp/std.pcm \ -o /tmp/mod /tmp/mod.cpp /tmp/std.pcm; then echo "MODULE-LINK-OK" @@ -175,3 +216,124 @@ jobs: fi file /tmp/mod || true set +x + + ios-engine: + name: iOS - mcpp builds and the simulator runs it + runs-on: macos-15 + timeout-minutes: 40 + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - uses: ./.github/actions/setup-macos-llvm + + - name: Build mcpp from source (self-host) + run: | + export MCPP_VENDORED_XLINGS="$XLINGS_BIN" + "$MCPP" build + echo "MCPP_DEV=$(ls -td "$PWD"/target/*/*/bin/mcpp | head -1)" >> "$GITHUB_ENV" + + # THE THREE ROWS ARE `planned`, AND THAT IS THE STATE THIS JOB EXISTS TO + # CHANGE. A planned row is refused with its own escape hatch named: + # + # error: target 'aarch64-ios' is registered but not yet supported + # (planned) -- An explicit [target.aarch64-ios] toolchain + # override can opt in early. + # + # so the fixture writes that override. Once this job is green the tiers + # move and the override becomes unnecessary; keeping it until then is + # what makes the measurement possible before the claim is made. + - name: "Fixture: a project that imports std and prints 1-2-3" + run: | + set -euo pipefail + mkdir -p /tmp/iostest/src + cat > /tmp/iostest/mcpp.toml << 'TOML' + [package] + name = "iostest" + version = "0.1.0" + + [build] + ios_deployment_target = "18.0" + + [target.aarch64-ios] + toolchain = "llvm@22.1.8" + + [target.aarch64-ios-sim] + toolchain = "llvm@22.1.8" + + [target.x86_64-ios-sim] + toolchain = "llvm@22.1.8" + TOML + cat > /tmp/iostest/src/main.cpp << 'CPP' + import std; + int main() { + std::vector v{3, 1, 2}; + std::ranges::sort(v); + std::print("{}-{}-{}\n", v[0], v[1], v[2]); + } + CPP + cat /tmp/iostest/mcpp.toml + + # D1/D2: THE DEVICE ROW'S ARTEFACT. Nothing runs it -- that needs a + # signature the developer owns -- so the claim is about the artefact: + # arm64 Mach-O naming the iOS platform and the project's deployment + # target in LC_BUILD_VERSION. A build that merely succeeds cannot tell + # those apart from a macOS binary. + - name: "D1: mcpp build --target aarch64-ios" + continue-on-error: true + run: | + set -x + cd /tmp/iostest + "$MCPP_DEV" build --target aarch64-ios + set +x + ART=$(find /tmp/iostest/target/aarch64-ios -name iostest -type f | head -1) + echo "artefact=$ART" + file "$ART" + otool -l "$ART" | grep -A5 LC_BUILD_VERSION + + # D4's PREMISE: whether `simctl spawn` takes a bare Mach-O. If it does, + # the `simctl-run` program in xim:apple-simulator-tools is a + # boot-and-spawn wrapper; if it does not, it has to synthesise a bundle, + # sign it and install it. This is the measurement that decides which. + - name: "D4: mcpp build --target aarch64-ios-sim, then simctl spawn" + continue-on-error: true + run: | + set -x + cd /tmp/iostest + "$MCPP_DEV" build --target aarch64-ios-sim + set +x + ART=$(find /tmp/iostest/target/aarch64-ios-sim -name iostest -type f | head -1) + echo "artefact=$ART" + file "$ART" + otool -l "$ART" | grep -A5 LC_BUILD_VERSION + UDID=$(xcrun simctl list devices available \ + | grep -A50 -- '-- iOS' \ + | grep -m1 -oE '[0-9A-F]{8}-[0-9A-F-]{27}' || true) + echo "udid=[$UDID]" + if [ -z "$UDID" ]; then echo "NO-IOS-SIMULATOR-DEVICE"; exit 0; fi + xcrun simctl boot "$UDID" || true + xcrun simctl bootstatus "$UDID" -b 2>&1 | tail -3 || true + if xcrun simctl spawn "$UDID" "$ART"; then + echo "SPAWN-OK" + else + echo "SPAWN-FAILED exit=$?" + fi + + # THE HOST SURFACE IS BOUNDED, AND ITS ABSENCE NAMES THE SDK. The + # recorded rule is that a host dependency must be minimal, named, and + # never a fallthrough -- so the refusal is a claim like any other, and + # `SDKROOT` pointing at a macOS SDK is the cheapest way to make the iOS + # SDK unlocatable without breaking the rest of the machine. + # + # AND NO OTHER ROW CHANGES: the same environment builds for the host. + - name: "D: the refusal names the SDK, and no other row is affected" + continue-on-error: true + run: | + set -x + cd /tmp/iostest + DEVDIR=/nonexistent-developer-dir + env DEVELOPER_DIR="$DEVDIR" SDKROOT="" \ + "$MCPP_DEV" build --target aarch64-ios 2>&1 | tail -12 + set +x + echo "--- and the host row still builds in the same environment ---" + env DEVELOPER_DIR="$DEVDIR" "$MCPP_DEV" build 2>&1 | tail -4 || true diff --git a/modules/toolchain-model/src/model.cppm b/modules/toolchain-model/src/model.cppm index 44835775..34e25ee6 100644 --- a/modules/toolchain-model/src/model.cppm +++ b/modules/toolchain-model/src/model.cppm @@ -181,6 +181,30 @@ struct Toolchain { // zero-libc tier and on hosted targets. std::string targetSysrootPkg; std::filesystem::path targetSysrootLib; + // THE APPLE SDK THIS TARGET WAS LOCATED AGAINST, and it is a different + // axis from the three paths above. + // + // `targetSysroot*` name a PACKAGE's directories -- a C library this + // ecosystem ships and can therefore version. An Apple SDK is located, not + // installed: the iPhoneOS and iPhoneSimulator headers and stub libraries + // ship inside Xcode and are not redistributable, so there is nothing for + // a package to name and the row carries no `sysroot` entry. What it + // carries instead is this, resolved once where the target is known. + // + // Empty for every non-Apple target AND for a native macOS build, which + // needs none: that build reads the payload's own `clang++.cfg`, and + // `post_install.cppm` wrote the located macOS SDK into it. An Apple CROSS + // target suppresses that cfg (`--no-default-config`) precisely because it + // names the wrong platform -- measured 2026-09-11 on macos-15, where an + // iOS-simulator link with the right `-isysroot` on the command line still + // picked the cfg's SDK: + // + // ld64.lld: error: .../MacOSX.sdk/usr/lib/libc++.tbd(...) is + // incompatible with arm64 (iOS Simulator18.0.0) + // + // so the SDK has to travel explicitly, on the compile side as well as the + // link side. + std::filesystem::path appleSdkRoot; std::vector compilerRuntimeDirs; // LD_LIBRARY_PATH for private tools std::vector linkRuntimeDirs; // -L/-rpath dirs for produced binaries // Environment the toolchain's tools need when invoked (set on the ninja diff --git a/modules/toolchain-model/src/triple.cppm b/modules/toolchain-model/src/triple.cppm index fc277acf..c75aae40 100644 --- a/modules/toolchain-model/src/triple.cppm +++ b/modules/toolchain-model/src/triple.cppm @@ -712,6 +712,27 @@ inline constexpr TargetInfo kKnownTargets[] = { // linker and the Apple half of the toolchain model all exist; what is // missing is an `os` value and the iPhoneOS SDK. // + // THE COMPILER IS OURS AND ONLY THE SDK IS APPLE'S, which is the sentence + // that shrank this row from a packaging problem to a located directory. + // + // `llvm@22.1.8` -- any sufficiently new clang emits arm64 Mach-O for an + // iOS deployment target, and the C++ runtime comes from the SDK the way it + // does on every Apple platform. `aarch64-macos` is verified on exactly + // this split and is the precedent: `xim:llvm` compiles and the SDK is + // located. Xcode's own clang would also work and is the wrong default -- + // it would make the compiler a host dependency where the ecosystem + // already has one, and these would be the only Apple rows not using + // `xim:llvm`. + // + // A CONVENTION PIN, NOT A CAPABILITY ONE, which is why + // `pin_is_capability()` does not name these rows. It means "this is what + // `--target aarch64-ios` resolves when the project says nothing", and a + // project may still write `[target.aarch64-ios] toolchain = "..."`. The + // capability set is the rows NO other payload can serve, and the + // distinction matters for consistency with `aarch64-macos`: the same + // constraint (Darwin needs clang) holds there and that row is not a + // capability row either. + // // THE SDK IS A LICENCE QUESTION AND NOT A PACKAGING ONE, which is why this // row carries no `sysroot`. The NDK is Apache-2.0 and Emscripten is MIT, // both redistributable; the iPhoneOS SDK is neither. The recipe should @@ -725,7 +746,7 @@ inline constexpr TargetInfo kKnownTargets[] = { // The simulator is deliberately not a row. It has its own SDK and produces // its own object, so folding it in would make two targets share an // identity -- the mistake `x86_64-windows-musl` was added to undo. - { "aarch64-ios", "planned", "", "", "", false }, + { "aarch64-ios", "planned", "", "llvm@22.1.8","", false }, // THE SIMULATOR'S TWO ROWS. Not a convenience and not a runner: a // simulator build has its own SDK (`iPhoneSimulator.sdk`), produces its own // object, and takes `-mios-simulator-version-min` rather than @@ -743,8 +764,8 @@ inline constexpr TargetInfo kKnownTargets[] = { // redistributable than the iPhoneOS one. What these rows buy today is that // `mcpp build --target aarch64-ios-sim` answers `tier-planned` naming the // row, instead of `unknown target`, which was false. - { "aarch64-ios-sim", "planned", "", "", "", false }, - { "x86_64-ios-sim", "planned", "", "", "", false }, + { "aarch64-ios-sim", "planned", "", "llvm@22.1.8","", false }, + { "x86_64-ios-sim", "planned", "", "llvm@22.1.8","", false }, // WEB IS THE OUTLIER, AND IT IS THE ONLY ONE OF THE THREE THAT CHANGES THE // MODEL RATHER THAN EXTENDING A TABLE. A new arch (`wasm32`), a new os diff --git a/src/build/distribution.cppm b/src/build/distribution.cppm index b269c831..4e7c7a15 100644 --- a/src/build/distribution.cppm +++ b/src/build/distribution.cppm @@ -314,6 +314,20 @@ struct MechanismInput { // mechanism exists to make that floor real, so without one there is // nothing to make real. bool macosFloor = false; + // AN APPLE CROSS TARGET -- the iOS rows, device and simulator. + // + // Same format as macOS and a different platform, which matters here + // because the payload's `libc++.a` is a MACH-O ARCHIVE BUILT FOR macOS. + // ld64 refuses an object built for one platform in a link for another, so + // the self-contained cell -- the one macOS uses to make its deployment + // floor real -- cannot apply, and `haveCxxArchives` says nothing about + // it: the archives exist, they are simply the wrong platform's. + // + // This is a distinct input and not a derivation from `macosFloor`, + // because `macosFloor` answers "did a macOS deployment target resolve" + // and an iOS build resolves one too (its default is a macOS version, + // which is exactly why it must not be consulted here). + bool appleCrossTarget = false; // Bare metal — there is no C++ runtime to distribute WITH. // // Every cell of the table below answers "how does this artifact carry its @@ -481,6 +495,36 @@ Mechanism resolve(const MechanismInput& in) { } return m; } + // iOS TAKES ITS C++ RUNTIME FROM THE SDK, AND HAS NO OTHER OPTION. + // + // Every iOS release ships libc++ in the OS, and the SDK's + // `libc++.tbd` is the stub that links against it -- so `-lc++` is + // both the correct and the only answer for these rows. The two + // alternatives are closed by construction rather than by policy: the + // payload's static archives are built for macOS and ld64 refuses + // them in an iOS link, and the payload's libc++.dylib is not present + // on a device at all. + // + // The contract vocabulary calls this `HostCoupled`, which reads + // oddly for a cross target; what it means in every cell is "the C++ + // runtime comes from the system the ARTEFACT RUNS ON", and for these + // rows that system is iOS. The deployment floor is still real, and + // it is carried by the effective triple (`arm64-apple-ios18.0`) + // rather than by a static archive. + if (in.appleCrossTarget) { + m.effective = Contract::HostCoupled; + m.unitFlags = " -lc++"; + if (in.requested != Contract::HostCoupled && in.explicitRequest) { + m.degraded = true; + m.diagnostic = std::format( + "cxx_runtime = \"{}\" is not available for an iOS target: " + "the toolchain's libc++ archives are built for macOS and " + "ld64 refuses them in an iOS link. Using the SDK's libc++, " + "which every iOS release ships; the deployment floor is " + "carried by the target triple", to_string(in.requested)); + } + return m; + } if (in.requested == Contract::ToolchainCoupled) { // The toolchain's libc++.dylib is a dead end on this // distribution: LLVM's macOS libc++abi/libunwind dylibs diff --git a/src/build/flags.cppm b/src/build/flags.cppm index 2394eb8c..3fa24845 100644 --- a/src/build/flags.cppm +++ b/src/build/flags.cppm @@ -605,6 +605,10 @@ CompileFlags compute_flags(const BuildPlan& plan) { mcpp::toolchain::HostFlagOptions hopt; hopt.cfgBypass = mcpp::toolchain::HostFlagOptions::CfgBypass::Always; hopt.macosDeploymentTarget = macosDeploymentTarget; + // READ, NOT RE-DERIVED. `prepare` located this once, where the target + // and the machine's Xcode are both known, and refused there if it was + // absent -- so a second `sdk_path()` call here could only disagree. + hopt.appleSdkRoot = plan.toolchain.appleSdkRoot; // THE SAME EXPRESSION THE LINK SIDE ASKS, twenty lines further down // (`plan.targetSide.cAbi.prebuilt()`). Reading one value at both sites // is what makes it impossible for them to disagree — which they did, @@ -1102,6 +1106,11 @@ CompileFlags compute_flags(const BuildPlan& plan) { bc.linkage, bc.cxxRuntime); mi.mingw = isMingwTc; mi.macosFloor = !macosDeploymentTarget.empty(); + // READ from the one value prepare resolved. The SDK being located for + // this target IS the statement "this is an Apple cross"; deriving it + // again from the triple here would be a second answer to a question + // already settled where the machine's Xcode was consulted. + mi.appleCrossTarget = !plan.toolchain.appleSdkRoot.empty(); mi.format = format; // A SECOND C++ RUNTIME ON THIS LINE. // @@ -1519,8 +1528,29 @@ CompileFlags compute_flags(const BuildPlan& plan) { // via xlings-res (data-only change — swap the archive source). // Tracked in xlings // .agents/docs/2026-06-05-macos-min-version-support.md §5. + // APPLE HAS TWO PLATFORMS HERE NOW, AND THE HOST IS ONLY THE FIRST. + // + // This branch is selected by the HOST (`needs_explicit_libcxx`), which + // was the same question while macOS was the only Apple target: a macOS + // host built for macOS. The iOS rows are built on the same host for + // another platform, and two tokens below are platform-specific -- + // which SDK, and which deployment-target flag. `appleSdkRoot` being + // non-empty is what says the target is the cross one; prepare resolves + // it and refuses there when the SDK cannot be located. + const bool appleCross = !plan.toolchain.appleSdkRoot.empty(); + std::string version_min; - if (!macosDeploymentTarget.empty()) { + // THE iOS DEPLOYMENT TARGET IS NOT A FLAG HERE, AND THAT IS ONE + // MECHANISM RATHER THAN TWO. It travels in the effective triple -- + // `arm64-apple-ios18.0`, `arm64-apple-ios18.0-simulator`, Apple's own + // spellings -- which fully determines the platform and the minimum, + // and `crossTarget` already carries that triple onto both the compile + // and the link line. macOS keeps its flag because its effective triple + // is also versioned and the flag was documented as insurance against + // env propagation; adding the iOS equivalent would be a second place + // that answers a question the triple already answered, and clang + // refuses the macOS flag against an iOS triple outright. + if (!appleCross && !macosDeploymentTarget.empty()) { version_min = " -mmacosx-version-min=" + macosDeploymentTarget; } // Pass the macOS SDK to the LINKER explicitly. The link otherwise relies @@ -1531,9 +1561,25 @@ CompileFlags compute_flags(const BuildPlan& plan) { // installed bundled clang — ld64.lld dies with "library not found for // -lSystem". -isysroot makes it deterministic regardless of the host's // developer-tools state. (compile side already gets --sysroot above.) + // + // FOR THE CROSS ROWS IT IS THE LOCATED iOS SDK, and the same + // determinism argument applies with more force: the payload's cfg + // names the macOS SDK, so without this the link resolves `-lSystem` + // and `-lc++` from the wrong platform's stubs. Measured 2026-09-11 on + // macos-15 with the correct `-isysroot` on the command line and the + // cfg NOT suppressed: + // + // ld64.lld: error: .../MacOSX.sdk/usr/lib/libc++.tbd(...) is + // incompatible with arm64 (iOS Simulator18.0.0) + // + // which is why the cross path also carries `--no-default-config`. std::string macos_sdk; - if (auto sdk = mcpp::platform::macos::sdk_path()) + if (appleCross) { + macos_sdk = " -isysroot " + + escape_path(plan.toolchain.appleSdkRoot); + } else if (auto sdk = mcpp::platform::macos::sdk_path()) { macos_sdk = " -isysroot " + escape_path(*sdk); + } // AND KEPT, BECAUSE THE GRAPH BRANCH BELOW REPLACES THIS LINE. // // `-isysroot` and the deployment floor describe the TARGET OS. Every @@ -1550,10 +1596,35 @@ CompileFlags compute_flags(const BuildPlan& plan) { // the platform interface IS a library. Linux needs no equivalent: its // kernel interface is an instruction, so a self-contained libc from the // graph really does replace everything. + // AND AN APPLE CROSS LINK HAS TO SAY WHICH PLATFORM, WHICH THIS + // BRANCH NEVER DID. + // + // `--target=` lives in `link_toolchain_flags`, and the note two + // hundred lines below records that ONLY THE THIRD BRANCH EVER + // CONSUMED IT -- correct while this branch meant "a macOS host + // building for macOS", where the driver's default target is the + // answer. An iOS row is the first target this branch serves that the + // driver would get wrong, and the artefact of a link told nothing is + // a macOS binary that compiled as iOS. + // + // `--no-default-config` for the same reason it is emitted on the + // compile side: that cfg names the located macOS SDK, and a link that + // reads it resolves `-lSystem` and `-lc++` from the wrong platform's + // stubs -- measured, see `macos_sdk` above. + // + // NOT folded into `platformAnchor`: that string is re-used by the + // graph-supplied-target replacement below, which assembles its own + // `--target=` and `--no-default-config`, and appending them twice + // would make the outcome depend on the driver's flag ordering. + std::string apple_cross_ld; + if (appleCross) { + apple_cross_ld = crossTarget; + if (isClangWithCfg) apple_cross_ld += " --no-default-config"; + } platformAnchor = macos_sdk + version_min; - f.ld = std::format("{}{}{} -fuse-ld=lld{}{}{}{}", full_static, - b_flag, macos_sdk, version_min, link_intent_ld, - user_ldflags, link_extra); + f.ld = std::format("{}{}{}{} -fuse-ld=lld{}{}{}{}", full_static, + b_flag, apple_cross_ld, macos_sdk, version_min, + link_intent_ld, user_ldflags, link_extra); // macOS decides the C++ runtime in the contract table (MachO), which // rides unit_ldflags — this line has nothing C++-only on it. f.ldC = f.ld; diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 7fd95dba..aa2e028e 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -79,6 +79,7 @@ import mcpp.runtime.binding; import mcpp.platform.runtime_search; import mcpp.toolchain.post_install; import mcpp.platform; +import mcpp.platform.macos; import mcpp.fetcher; import mcpp.fetcher.progress; import mcpp.pm.resolver; @@ -3509,6 +3510,66 @@ prepare_build(bool print_fingerprint, for (auto const& def : stdDefines) tc->stdModuleTargetFlags += " -D" + def; } + + // ── iOS: THE COMPILER IS OURS, THE SDK IS THE MACHINE'S ── + // + // The three iOS rows pin `llvm@22.1.8` -- any sufficiently + // new clang emits arm64 Mach-O for an iOS deployment target + // -- and take their headers and stub libraries from the + // machine's Xcode, which is where the whole item shrinks to + // a located sysroot. `aarch64-macos` is verified on exactly + // this split and is the precedent. + // + // LOCATED HERE, ONCE. Three later sites need the answer (the + // compile flags, the link line, and the std module's own + // command), and a function that probes the machine is the + // wrong thing to call three times: `xcrun` shells out, and + // three answers can differ if the developer directory + // changes mid-build. + // + // AND ITS ABSENCE IS A REFUSAL THAT NAMES THE SDK. The + // recorded host-surface rule is that a host dependency must + // be minimal, named, and never a fallthrough; the iOS SDK + // and `simctl` are the two this platform adds, both in the + // "proprietary runtime that exists only on its own OS" + // category. A build that continued without the SDK would + // fail in the driver's header search, naming a file rather + // than the thing that is missing. + if (want->is_ios()) { + const auto which = + want->is_ios_simulator() + ? mcpp::platform::macos::sdk_iphonesim + : mcpp::platform::macos::sdk_iphoneos; + auto sdk = mcpp::platform::macos::sdk_path(which); + if (!sdk) { + return std::unexpected(std::format( + "target {} needs the {} SDK, which this machine " + "does not provide.\n" + " It is not redistributable, so mcpp " + "LOCATES it rather than installing it: " + "`xcrun --sdk {} --show-sdk-path` must answer, " + "which needs Xcode (not the Command Line Tools " + "alone -- those ship the macOS SDK only).\n" + " Check `xcode-select -p`, and note that " + "the compiler itself is not the problem: it is " + "`xim:llvm`, and every other Apple row builds " + "with it on this machine.", + want->str(), which, which)); + } + tc->appleSdkRoot = *sdk; + // AND THE std MODULE'S OWN COMMAND, WHICH IS A SEPARATE + // CHANNEL. Same reason the Android rows set it: the + // module is precompiled by `clang.cppm`'s own assembly + // rather than by the compile flags, so a decision made + // only in the flag builder reaches every translation + // unit and not the module they all import. Without the + // SDK here the precompile resolves libc++'s + // `#include <__config>` against the macOS SDK and the + // module is built for the wrong platform. + tc->stdModuleTargetFlags = + " " + tc->crossTargetFlag + + " -isysroot " + sdk->string(); + } } } if (auto want = mcpp::toolchain::triple::parse(overrides.target_triple); diff --git a/src/toolchain/hostflags.cppm b/src/toolchain/hostflags.cppm index 2c680195..dd9ea076 100644 --- a/src/toolchain/hostflags.cppm +++ b/src/toolchain/hostflags.cppm @@ -82,8 +82,23 @@ struct HostFlagOptions { // Resolved value from platform::macos::deployment_target(); empty = omit. // Must agree across the std BMI and everything that imports it — clang // rejects a module built for a different deployment target outright. + // + // A macOS VERSION, so it is emitted only for a macOS target. An iOS + // target carries its own version space (`ios_deployment_target`) and + // clang REFUSES the two together -- `-mmacosx-version-min` with an + // `arm64-apple-ios…` triple is an error, not a no-op -- which is why + // `appleSdkRoot` below is the discriminator rather than a second version + // field: exactly one of the two Apple platforms is ever in play. std::string macosDeploymentTarget; + // THE LOCATED APPLE SDK, for a target whose SDK is not the host's. + // + // Read from `Toolchain::appleSdkRoot`, which prepare resolves once. It is + // non-empty only for the iOS rows, and its presence is what says "this is + // an Apple cross": the deployment-target flag above then belongs to the + // other platform and is withheld. + std::filesystem::path appleSdkRoot; + // DOES THE TARGET'S C LIBRARY COME FROM A DIRECTORY THAT EXISTED // BEFORE DEPENDENCY RESOLUTION? — `plan.targetSide.cAbi.prebuilt()`, READ // rather than derived. @@ -359,9 +374,34 @@ std::vector host_compile_tokens(const Toolchain& tc, // object step, and the build.mcpp compile. Skipping it on the trust-cfg // path is exactly the mismatch e2e 181 catches: the std BMI is built for // 14.0 while the TU importing it is not. - if (mcpp::platform::is_macos && !opt.macosDeploymentTarget.empty()) + // + // AND ONLY FOR A macOS TARGET. This asked whether the HOST is macOS, which + // was the same question while macOS was the only Apple target mcpp could + // build for. The iOS rows are built ON a macOS host and FOR another + // platform, and clang refuses the combination outright: + // + // error: invalid argument '-mmacosx-version-min=14.0' not allowed with + // 'arm64-apple-ios18.0' + // + // so the flag would not merely be useless there, it would stop the build. + // The iOS deployment target travels in the effective triple instead -- + // `arm64-apple-ios18.0` -- which is one place rather than two for the same + // value. + if (mcpp::platform::is_macos && !opt.macosDeploymentTarget.empty() + && opt.appleSdkRoot.empty()) out.push_back("-mmacosx-version-min=" + opt.macosDeploymentTarget); + // THE APPLE CROSS TARGET'S OWN SDK, on the compile side. + // + // A native macOS build needs nothing here: it reads the payload's + // `clang++.cfg`, which `post_install.cppm` filled with the located macOS + // SDK. An Apple cross suppresses that cfg because it names the wrong + // platform, so this is the only thing that tells the driver where the + // iPhoneOS headers are. `-isysroot` is JoinedOrSeparate in clang, so the + // joined form is one token like every other path here. + if (!opt.appleSdkRoot.empty()) + out.push_back("-isysroot" + esc(opt.appleSdkRoot)); + if (!trustCfg && !graphSuppliesTarget && (bypassCfg || lm.mode != CLibMode::None)) for (auto& t : lm.compile_tokens(esc)) out.push_back(t); diff --git a/tests/unit/test_toolchain_triple.cpp b/tests/unit/test_toolchain_triple.cpp index 077365e8..d3dd0095 100644 --- a/tests/unit/test_toolchain_triple.cpp +++ b/tests/unit/test_toolchain_triple.cpp @@ -736,11 +736,21 @@ TEST(Triple, EachRowsTierMatchesTheEvidenceThatExistsForIt) { EXPECT_TRUE(info->sysroot.empty()) << name; } - // THE THREE APPLE ROWS STAY `planned`, AND THE BLOCKER IS NOT A PAYLOAD. + // THE THREE APPLE ROWS, AND WHAT THE SDK'S LICENCE DOES AND DOES NOT + // BOUND. + // // The NDK is Apache-2.0 and Emscripten is MIT; the iPhoneOS and - // iPhoneSimulator SDKs ship inside Xcode and are neither. No amount of - // engine work moves these, which is why they carry no pin: there is - // nothing for a pin to name. + // iPhoneSimulator SDKs ship inside Xcode and are neither. That bounds the + // SYSROOT -- which is why these rows carry no `sysroot` entry, since that + // column names a package and a located directory is not one -- and it + // does NOT bound the compiler: `xim:llvm` emits arm64 Mach-O for an iOS + // deployment target, so the rows pin it exactly as `x86_64-windows-musl` + // does. + // + // A CONVENTION PIN. It answers "what does `--target aarch64-ios` resolve + // when the project says nothing", and remains overridable -- asserted + // below in ExactlyTheseRowsHaveACapabilityPin, whose expected set does + // NOT contain these three. for (auto [name, tier] : {std::pair{"aarch64-ios", "planned"}, std::pair{"aarch64-ios-sim", "planned"}, std::pair{"x86_64-ios-sim", "planned"}}) { @@ -750,7 +760,9 @@ TEST(Triple, EachRowsTierMatchesTheEvidenceThatExistsForIt) { auto* info = find_known_target(*t); ASSERT_NE(info, nullptr) << name; EXPECT_EQ(info->tier, tier) << name; + EXPECT_EQ(info->pin, "llvm@22.1.8") << name; EXPECT_TRUE(info->sysroot.empty()) << name; + EXPECT_FALSE(t->pin_is_capability()) << name; } } From 564e0dd65b3e872836a77eb0b81fd6e5245d1014 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Fri, 11 Sep 2026 19:44:15 +0800 Subject: [PATCH 05/14] fix(toolchain): a path is absolute by the host's convention, and a payload names its C compiler its own way TWO DEFECTS, BOTH FOUND BY THE WINDOWS JOB AND THE SECOND BY A SECOND PAYLOAD. A DESCRIPTOR'S `frontend` WAS VALIDATED THROUGH `std::filesystem::path`, WHOSE ANSWERS DIFFER BY HOST WHILE THE DESCRIPTOR DOES NOT. Measured on a Windows runner: `path("/usr/bin/g++").is_absolute()` is FALSE there -- the path has a root directory and no root NAME, which Windows calls root-relative -- so the guard passed, and `payloadRoot / "/usr/bin/g++"` then resolves to `C:/usr/bin/g++`. A host compiler, chosen by a package, on the one host where the check did not look. That is precisely the property the guard exists to prevent: mcpp's hermeticity is a property of the payload boundary, not of a recipe's good manners. The field is one shape on every host -- relative, `/`-separated, plain components -- so it is now validated as a STRING and positively: no backslash, no leading `/`, no drive or scheme, and no empty, `.` or `..` component. Asking a path type whether it is absolute is asking a question whose meaning the host supplies. Three more cases join the table, and each fails when the check is removed. AND THE FIXTURE TESTED ONE HOST. It created `bin/clang++` while the built-in guess searches `bin/clang++.exe`, so "no descriptor resolves exactly as before" failed on the host where the executable suffix exists -- a test about the descriptor failing for a reason that had nothing to do with it. The frontend name now comes from `platform::exe_suffix`, and the descriptor case deliberately uses a name the guess would never try, which is what makes that assertion about the descriptor. `em++`'s C COMPILER IS `emcc`, NOT `em`. Dropping `++` is clang's rule and was being applied as the rule; `g` was already a special case for exactly this reason, and a second driver of the same shape arrived without one. Measured on the conformance suite's one C translation unit for `wasm32-emscripten`: /bin/sh: 1: .../xim-x-emsdk/6.0.9/emscripten/em: not found A table rather than a third `if`, because the property is "this driver names its C compiler with a different word" and a table can be read as the list of drivers for which that is true. The derivation is exported so the mapping can be asserted without a toolchain, and the test enumerates every frontend spelling `to_xim_package` can resolve -- a payload added there without a row here is a payload whose C units compile with a program that does not exist. THE iOS SDK IS LOCATED BEFORE ANY PAYLOAD IS RESOLVED. It was located where the toolchain is, so a machine without Xcode downloaded a 700 MB compiler and was then told the thing it was missing was not the compiler. Unlike `host_can_serve` it is not deferred: an Apple SDK is not redistributable, so no package supplies it and no later line can change the answer. The escape hatch that opens the tier gate does not open this one -- declaring a toolchain says which compiler and says nothing about where the headers are. Measured on Linux, which is where this claim can be made: with an explicit `[target.aarch64-ios] toolchain` and MCPP_NO_AUTO_INSTALL=1, the refusal arrives before the resolution line and 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 what is missing. AND THE macOS CI LEG'S OWN VERSION OF THAT CLAIM WAS MEASURING NOTHING. With `DEVELOPER_DIR` pointed at a nonexistent directory the iOS build SUCCEEDED -- `xcrun` ignores an invalid developer directory and falls back -- so the step asserted a refusal that did not happen. The predicate was right and the object was wrong; the claim now lives where the SDK is genuinely absent. WHAT THE macOS RUN DID MEASURE, all on macos-15 with Xcode 16.4: xcrun --sdk iphoneos iPhoneOS18.5.sdk xcrun --sdk iphonesimulator iPhoneSimulator18.5.sdk simctl runtimes iOS 18.5, 18.6, 26.0, 26.1, 26.2 simctl spawn 1-2-3, exit 0 <- no bundle, no signature SDK libc++ links payload static libc++ refused, as predicted for a macOS archive import std, located SDK precompiles and links mcpp build --target aarch64-ios platform 2 (IOS), minos 18.0 mcpp build --target aarch64-ios-sim platform 7 (IOSSIM), minos 18.0 simctl spawn 1-2-3 --- src/build/prepare.cppm | 83 +++++++++++++---- src/toolchain/registry.cppm | 109 +++++++++++++++++++---- tests/unit/test_toolchain_descriptor.cpp | 35 ++++++-- tests/unit/test_toolchain_registry.cpp | 55 ++++++++++++ 4 files changed, 242 insertions(+), 40 deletions(-) diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index aa2e028e..1b65aa01 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -1652,6 +1652,14 @@ prepare_build(bool print_fingerprint, // the graph can supply the target's system, and the graph is not known // here. Held until it is, and released only if nothing supplies it. std::string unservedTargetDiagnosis; + + // THE LOCATED APPLE SDK, RESOLVED ONCE AND READ ONCE. + // + // `xcrun` is a process. Calling it at the refusal below and again where + // the answer is stored would be two calls whose answers can differ -- the + // developer directory can be switched between them -- and this repository + // has a standing rule that a value crossing two sites is resolved at one. + std::optional appleSdkLocated; // Non-empty when a target row's convention replaced a toolchain the user // had set with `mcpp toolchain default`. Reported on the status line, // because a substitution nobody is told about is a rule that can only be @@ -2460,6 +2468,45 @@ prepare_build(bool print_fingerprint, " An explicit [target.{}] toolchain override can opt in early.", subject, parsed->str())); } + // AN APPLE SDK IS LOCATED, SO ITS ABSENCE IS KNOWN NOW. + // + // REFUSED HERE AND NOT WITH THE TOOLCHAIN, which is a decision about + // WHEN rather than about the message. The iOS rows need the machine's + // iPhoneOS or iPhoneSimulator SDK, and that is knowable before any + // payload is resolved -- so a machine without Xcode used to download + // a 700 MB compiler and then be told the thing it was missing was not + // the compiler. + // + // AND UNLIKE `host_can_serve` BELOW, THIS IS NOT DEFERRED. That + // refusal waits for the dependency graph because a package can supply + // a target's C library and platform interface. An Apple SDK is not + // redistributable, so no package supplies it: there is nothing a later + // line could learn that would change this answer. + // + // The escape hatch that opens the tier gate does NOT open this one. + // Declaring a toolchain says which compiler; it says nothing about + // where the headers and stub libraries are, and every compiler needs + // them. + if (parsed && parsed->is_ios()) { + const auto which = parsed->is_ios_simulator() + ? mcpp::platform::macos::sdk_iphonesim + : mcpp::platform::macos::sdk_iphoneos; + appleSdkLocated = mcpp::platform::macos::sdk_path(which); + if (!appleSdkLocated) { + return std::unexpected(std::format( + "target {} needs the {} SDK, which this machine does not " + "provide.\n" + " It is not redistributable, so mcpp LOCATES it " + "rather than installing it: `xcrun --sdk {} " + "--show-sdk-path` must answer, which needs Xcode on macOS " + "(not the Command Line Tools alone -- those ship the " + "macOS SDK only).\n" + " Check `xcode-select -p`, and note that the " + "compiler is not what is missing: these rows pin " + "`xim:llvm`, which every other Apple row also uses.", + parsed->str(), which, which)); + } + } // Known, supported — and IMPOSSIBLE ON THIS HOST. // // Without this the target falls through to the host toolchain and the @@ -3536,27 +3583,25 @@ prepare_build(bool print_fingerprint, // fail in the driver's header search, naming a file rather // than the thing that is missing. if (want->is_ios()) { - const auto which = - want->is_ios_simulator() - ? mcpp::platform::macos::sdk_iphonesim - : mcpp::platform::macos::sdk_iphoneos; - auto sdk = mcpp::platform::macos::sdk_path(which); - if (!sdk) { + // READ, NOT RE-DERIVED. The refusal above located it + // before any payload was resolved, and that is the one + // `xcrun` call this build makes. + // + // An empty answer here cannot happen through the + // `--target` path, and a line that prints when it does + // is cheaper than a branch that pretends it cannot: the + // row could be reached one day by a route that skipped + // the gate, and an iOS build with no `-isysroot` is a + // macOS artefact with an iOS triple on it. + if (!appleSdkLocated) { return std::unexpected(std::format( - "target {} needs the {} SDK, which this machine " - "does not provide.\n" - " It is not redistributable, so mcpp " - "LOCATES it rather than installing it: " - "`xcrun --sdk {} --show-sdk-path` must answer, " - "which needs Xcode (not the Command Line Tools " - "alone -- those ship the macOS SDK only).\n" - " Check `xcode-select -p`, and note that " - "the compiler itself is not the problem: it is " - "`xim:llvm`, and every other Apple row builds " - "with it on this machine.", - want->str(), which, which)); + "internal: target {} reached toolchain " + "resolution without its SDK being located; the " + "gate that locates it did not run for this " + "request", want->str())); } - tc->appleSdkRoot = *sdk; + tc->appleSdkRoot = *appleSdkLocated; + auto sdk = appleSdkLocated; // AND THE std MODULE'S OWN COMMAND, WHICH IS A SEPARATE // CHANNEL. Same reason the Android rows set it: the // module is precompiled by `clang.cppm`'s own assembly diff --git a/src/toolchain/registry.cppm b/src/toolchain/registry.cppm index 0a60478c..22112908 100644 --- a/src/toolchain/registry.cppm +++ b/src/toolchain/registry.cppm @@ -374,6 +374,12 @@ std::vector available_toolchain_indexes(); std::filesystem::path derive_c_compiler(const Toolchain& tc); +// The same derivation as a pure function of the path, so that the mapping can +// be stated once and asserted without a toolchain. Exported because it is the +// whole of what can be wrong in it: every frontend this engine resolves has a +// C driver beside it, and the rule for naming it differs by driver. +std::filesystem::path derive_c_compiler_path(const std::filesystem::path& cxxPath); + // A binutils-family tool for THIS toolchain's TARGET, named in the GNU // spelling ("ar", "strip", "objcopy"). // @@ -408,28 +414,72 @@ bool ends_with(std::string_view s, std::string_view suf) { && s.compare(s.size() - suf.size(), suf.size(), suf) == 0; } + +triple::Triple host_musl_triple() { + return { std::string(mcpp::platform::host_arch), "linux", "musl" }; +} + +} // namespace + +// THE C COMPILER BESIDE A C++ ONE, AND DROPPING `++` IS NOT THE RULE. +// +// It is the rule for clang and it is not for the others, which is why `g` was +// already a special case here: `g++`'s C compiler is `gcc` and not `g`. A +// second driver with the same shape arrived and the special case did not +// cover it. +// +// Measured 2026-09-11, compiling the conformance suite's one C translation +// unit for `wasm32-emscripten`: +// +// /bin/sh: 1: .../xim-x-emsdk/6.0.9/emscripten/em: not found +// +// `em++` became `em`, which is not a program. The C compiler is `emcc`. +// +// A TABLE RATHER THAN A THIRD `if`, because the property being encoded is "this +// driver names its C compiler with a different word", and a table can be read +// as the list of drivers for which that is true. The fallthrough -- drop the +// `++` -- stays correct for clang, for `-clang++`, and for every +// frontend candidate this engine resolves that is not in the table. std::filesystem::path derive_c_compiler_path(const std::filesystem::path& cxxPath) { auto stem = cxxPath.stem().string(); auto parent = cxxPath.parent_path(); auto ext = cxxPath.extension(); + // Each row is a C++ driver stem and the C driver beside it. Prefixed forms + // (`x86_64-w64-mingw32-g++`) match on the suffix, which is what keeps one + // row per driver rather than one per target triple. + struct Row { std::string_view cxx, c; }; + static constexpr Row kNamed[] = { + { "g++", "gcc" }, // GCC, native and triple-prefixed + { "em++", "emcc" }, // Emscripten + }; + std::string cc_stem; - if (stem.ends_with("++")) { - cc_stem = stem.substr(0, stem.size() - 2); - if (cc_stem == "g" || cc_stem.ends_with("-g")) - cc_stem += "cc"; - } else { - cc_stem = stem; + bool named = false; + for (auto const& row : kNamed) { + if (stem == row.cxx) { + cc_stem = std::string(row.c); + named = true; + break; + } + // `-g++` -> `-gcc`. The separator is required, so + // `clang++` does not match the `g++` row by ending in it. + const std::string suffix = "-" + std::string(row.cxx); + if (stem.size() > suffix.size() && ends_with(stem, suffix)) { + cc_stem = stem.substr(0, stem.size() - row.cxx.size()) + + std::string(row.c); + named = true; + break; + } + } + if (!named) { + cc_stem = stem.ends_with("++") + ? stem.substr(0, stem.size() - 2) + : stem; } return parent / (cc_stem + ext.string()); } -triple::Triple host_musl_triple() { - return { std::string(mcpp::platform::host_arch), "linux", "musl" }; -} - -} // namespace - std::expected parse_toolchain_spec(std::string compilerArg, std::string versionArg, @@ -817,13 +867,40 @@ read_payload_descriptor(const std::filesystem::path& payloadRoot) { // the one thing a payload must not be able to do: mcpp's hermeticity // is a property of the payload boundary, not of the recipe's good // manners. - std::filesystem::path rel(d.frontend); - if (rel.is_absolute()) + // + // VALIDATED AS A STRING AND NOT THROUGH `std::filesystem::path`, + // BECAUSE THAT TYPE'S ANSWERS DIFFER BY HOST AND THE DESCRIPTOR DOES + // NOT. + // + // Measured 2026-09-11 on a Windows runner: `path("/usr/bin/g++") + // .is_absolute()` is FALSE there -- the path has a root directory and + // no root NAME, which Windows calls root-relative -- so the check + // passed, and `payloadRoot / "/usr/bin/g++"` then resolves to + // `C:/usr/bin/g++`. A host compiler, chosen by a package, on the one + // host where the guard did not look. + // + // The descriptor's `frontend` is one shape on every host: relative, + // `/`-separated, plain components. Asserting that positively is + // host-independent by construction; asking a path type whether it is + // absolute is asking a question whose meaning the host supplies. + if (d.frontend.find('\\') != std::string::npos) + return refuse("\"frontend\" contains a backslash; the separator " + "is `/` on every host"); + if (d.frontend.front() == '/') return refuse("\"frontend\" is absolute; it is relative to the " "payload root"); - for (auto const& part : rel) - if (part == "..") + if (d.frontend.find(':') != std::string::npos) + return refuse("\"frontend\" names a drive or a scheme; it is a " + "path relative to the payload root"); + for (std::size_t i = 0, n = 0; i <= d.frontend.size(); ++i) { + if (i != d.frontend.size() && d.frontend[i] != '/') { ++n; continue; } + const auto part = d.frontend.substr(i - n, n); + n = 0; + if (part.empty()) + return refuse("\"frontend\" has an empty path component"); + if (part == "." || part == "..") return refuse("\"frontend\" leaves the payload root"); + } } if (j.contains("platform_floor")) { diff --git a/tests/unit/test_toolchain_descriptor.cpp b/tests/unit/test_toolchain_descriptor.cpp index bf9b8dca..3e0b4eb7 100644 --- a/tests/unit/test_toolchain_descriptor.cpp +++ b/tests/unit/test_toolchain_descriptor.cpp @@ -46,6 +46,16 @@ XimToolchainPackage bin_shaped_pkg() { return to_xim_package(*spec); } +// THE GUESS LOOKS FOR A HOST-SPECIFIC NAME, so a fixture that writes one name +// tests one host. Measured on a Windows runner: the fixture created +// `bin/clang++` and the guess searched `bin/clang++.exe`, so +// "no descriptor resolves exactly as before" failed on the host where the +// executable suffix exists -- a test that was about the descriptor failing for +// a reason that had nothing to do with it. +std::string host_frontend_name() { + return std::string("clang++") + std::string(mcpp::platform::exe_suffix); +} + } // namespace // ─── Absence is compatibility (C) ─────────────────────────────────────────── @@ -56,7 +66,7 @@ XimToolchainPackage bin_shaped_pkg() { // existed, which is what the assertion below measures. TEST(PayloadDescriptor, NoDescriptorResolvesExactlyAsBefore) { FakePayload fp{"none"}; - auto clang = fp.add_compiler("bin/clang++"); + auto clang = fp.add_compiler("bin/" + host_frontend_name()); auto pkg = bin_shaped_pkg(); auto d = read_payload_descriptor(fp.root); @@ -78,12 +88,15 @@ TEST(PayloadDescriptor, NoDescriptorResolvesExactlyAsBefore) { // from the descriptor. TEST(PayloadDescriptor, TheDescriptorAnswersWhereTheGuessCannot) { FakePayload fp{"frontend"}; + // NOT the host-specific name, deliberately: the descriptor says exactly + // where the compiler is, so the payload is free to spell it any way it + // likes and this fixture spells it a way the guess would never try. auto deep = fp.add_compiler( - "toolchains/llvm/prebuilt/some-host-tag/bin/clang++"); + "toolchains/llvm/prebuilt/some-host-tag/bin/frontend-by-descriptor"); std::filesystem::create_directories(fp.root / "bin"); // present, empty fp.write_descriptor(R"({ "schema": 1, - "frontend": "toolchains/llvm/prebuilt/some-host-tag/bin/clang++" + "frontend": "toolchains/llvm/prebuilt/some-host-tag/bin/frontend-by-descriptor" })"); auto pkg = bin_shaped_pkg(); @@ -106,7 +119,7 @@ TEST(PayloadDescriptor, TheDescriptorAnswersWhereTheGuessCannot) { // the recipe. TEST(PayloadDescriptor, ANamedFrontendThatIsAbsentIsEmptyNotAnError) { FakePayload fp{"absent"}; - fp.add_compiler("bin/clang++"); // the guess WOULD find this + fp.add_compiler("bin/" + host_frontend_name()); // the guess WOULD find this fp.write_descriptor(R"({"schema": 1, "frontend": "opt/bin/clang++"})"); auto found = payload_frontend(fp.root, bin_shaped_pkg()); @@ -131,10 +144,22 @@ TEST(PayloadDescriptor, EveryMalformedShapeIsRefusedByName) { { "a schema we lack", R"({"schema": 2})", "schema 2" }, { "frontend not string", R"({"schema":1,"frontend":42})", "frontend" }, { "frontend empty", R"({"schema":1,"frontend":""})", "frontend" }, + // ABSOLUTE BY EITHER CONVENTION, because a host's path type answers + // only for its own. `path("/usr/bin/g++").is_absolute()` is FALSE on + // Windows, so this case passed there and `payloadRoot / frontend` + // resolved to `C:/usr/bin/g++` -- a host compiler chosen by a package. { "frontend absolute", R"({"schema":1,"frontend":"/usr/bin/g++"})", "absolute" }, + { "frontend drive", R"({"schema":1,"frontend":"C:/Windows/gcc.exe"})", + "drive" }, + { "frontend backslash", R"({"schema":1,"frontend":"bin\\clang++.exe"})", + "backslash" }, { "frontend escapes", R"({"schema":1,"frontend":"../../usr/bin/g++"})", "leaves" }, + { "frontend dot", R"({"schema":1,"frontend":"./bin/clang++"})", + "leaves" }, + { "frontend empty part", R"({"schema":1,"frontend":"bin//clang++"})", + "empty path component" }, { "floor not a string", R"({"schema":1,"platform_floor":21})", "platform_floor" }, { "floor empty", R"({"schema":1,"platform_floor":""})", "platform_floor" }, { "floor not a version", R"({"schema":1,"platform_floor":"r30"})", @@ -150,7 +175,7 @@ TEST(PayloadDescriptor, EveryMalformedShapeIsRefusedByName) { }; for (auto const& c : cases) { FakePayload fp{"bad"}; - fp.add_compiler("bin/clang++"); + fp.add_compiler("bin/" + host_frontend_name()); fp.write_descriptor(c.json); auto d = read_payload_descriptor(fp.root); diff --git a/tests/unit/test_toolchain_registry.cpp b/tests/unit/test_toolchain_registry.cpp index 646ef933..6d4ce699 100644 --- a/tests/unit/test_toolchain_registry.cpp +++ b/tests/unit/test_toolchain_registry.cpp @@ -493,6 +493,61 @@ TEST(SdkPayloads, TheDisplayNamesThePayloadAndNotOnlyTheFamily) { } } +// ─── The C compiler beside a C++ one ─────────────────────────────────────── +// +// Every frontend this engine can resolve, and the C driver beside it. The +// DENOMINATOR IS `to_xim_package`'s own candidate lists: each spelling below +// appears in one of its branches, so a payload whose frontend is added there +// without a row here is a payload whose C units compile with a program that +// does not exist. +// +// That is not hypothetical. Measured 2026-09-11 on the conformance suite's one +// C translation unit for `wasm32-emscripten`: +// +// /bin/sh: 1: .../xim-x-emsdk/6.0.9/emscripten/em: not found +// +// `em++` had become `em`, because "drop the `++`" was the rule and it is only +// clang's rule. `g` was already a special case for exactly this reason, which +// is what makes a table the right shape: the property is "this driver names +// its C compiler with a different word". +TEST(SdkPayloads, EveryFrontendNamesTheCCompilerBesideIt) { + struct Case { std::string_view cxx, c; }; + const Case cases[] = { + // clang and its triple-prefixed forms: dropping `++` is correct. + { "clang++", "clang" }, + { "aarch64-linux-gnu-clang++", "aarch64-linux-gnu-clang" }, + // GCC: a different word, native and prefixed. + { "g++", "gcc" }, + { "x86_64-w64-mingw32-g++", "x86_64-w64-mingw32-gcc" }, + { "aarch64-linux-musl-g++", "aarch64-linux-musl-gcc" }, + // Emscripten: a different word, and the row this test was written for. + { "em++", "emcc" }, + // A name that is already a C driver stays itself. + { "clang", "clang" }, + { "emcc", "emcc" }, + }; + for (auto const& c : cases) { + const std::filesystem::path cxx = + std::filesystem::path("/p/bin") / std::string(c.cxx); + EXPECT_EQ(mcpp::toolchain::derive_c_compiler_path(cxx).filename().string(), + std::string(c.c)) << c.cxx; + } + + // AND THE EXECUTABLE SUFFIX SURVIVES, which is the part a stem-based + // rewrite loses: on Windows the payload ships `g++.exe`, and a C compile + // spawning `gcc` with no suffix finds nothing. + EXPECT_EQ(mcpp::toolchain::derive_c_compiler_path("/p/bin/g++.exe") + .filename().string(), "gcc.exe"); + EXPECT_EQ(mcpp::toolchain::derive_c_compiler_path("/p/bin/clang++.exe") + .filename().string(), "clang.exe"); + + // `clang++` MUST NOT MATCH THE `g++` ROW. It ends in `g++`'s last two + // characters plus nothing, and a suffix test without the separator would + // turn it into `clanccc` or worse -- a name that is nothing. + EXPECT_EQ(mcpp::toolchain::derive_c_compiler_path("/p/bin/clang++") + .filename().string(), "clang"); +} + // ─── One payload, one spelling (B1) ──────────────────────────────────────── // // `ndk` was accepted as an alias for `android-ndk`, and the capability gate From c3cb6ee43cf4b504a8daf32177c537a9d6de182d Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:09:32 +0800 Subject: [PATCH 06/14] feat(toolchain): a spec that names a payload has answered which payload, and the iOS rows are written down NEITHER `mcpp toolchain install emsdk` NOR `mcpp toolchain install android-ndk` HAD EVER WORKED, and openkal-emscripten's continuous integration is what found it: $ mcpp toolchain install emsdk 6.0.9 error: installed package has no known C++ frontend in '.../xim-x-emsdk/6.0.9/bin' TWO DEFECTS, ONE PER AXIS. `to_xim_package` decided the payload from the TARGET. That is right for the spelling a build uses -- `--target wasm32-emscripten` with nothing declared -- and there is no target at all in an install, so the emsdk spec resolved the generic llvm shape and looked for `clang++` in `bin/` while `em++` is in `emscripten/`. `payloadName` is the field `parse_toolchain_spec` fills from the spelling and is the more direct statement of the two: a spec that names a payload has answered this question. It decides first; the target remains the answer for a spec that names no payload, which is the escape-hatch spelling the capability gate has to be able to refuse. And the install path composed `/bin` itself. That was one of the five sites the `frontendSubdir` note in mcpp.toolchain.registry records; four were repaired and this one was not. It now asks `payload_frontend`, which is the same function the build path asks -- so the two cannot disagree, and the install picks up the payload's descriptor for free. AND THE FIXTURE OF THE TEST THAT COVERS THIS AXIS HAD PICKED ITS OBJECT BY CONVENIENCE. `TheTargetChoosesThePackageAndThePackageKnowsItsLayout` built every case from `emsdk@6.0.9` and replaced only the target, which worked while the target was the only thing read. It now builds them from `llvm@22.1.8`, which names the family and no payload -- the shape whose resolution that test exists to state. D5. THE iOS ROWS ARE WRITTEN DOWN, in docs/20 and its Chinese copy: the compiler is ours and only the SDK is Apple's, the two host dependencies this platform adds are named and bounded, the deployment target travels in the effective triple alone, and the simulator is two rows because it runs the host's architecture. `examples/13-platform-targets` gains the iOS rows with measured readings -- `platform 2` for the device and `platform 7` for the simulator, which is the one pair a successful build cannot tell apart -- and records two limitations it ran into rather than working around them: `deps` is not conditional on a target, and `xim:apple-simulator-tools` exists for macosx alone, so declaring the runner's package in the manifest would make this example's Linux build depend on a macOS-only package. The program is installed by whoever runs those rows. E4c. `docs/24-openkal-cross` and its Chinese copy stop saying the Web needs an implementation and name `openkal-emscripten`, with the three-group table that makes a partial surface a conformant one. The iOS section stops being a structural argument: what the SDK's licence bounds is packaging it, not locating it, and `aarch64-macos` has been verified on exactly that split since before these rows existed. e2e 641 gains the iOS claims that a non-Apple host can make: the rows name their payload, and the SDK's absence is a refusal that names the SDK, the `xcrun` command, the Command-Line-Tools note and the compiler -- arriving before any payload is resolved. The effective-triple claim is a unit test instead, because that same gate means a host without Xcode never prints the line an end-to-end check would read. AND THE macOS LEG NOW RUNS THE ARTEFACT THROUGH THE SUPPORTED PATH. The earlier leg spawned it by hand, which proves the platform can run it and not that mcpp can; `mcpp run --target aarch64-ios-sim` goes through the `runner` the manifest declares and the program `xim:apple-simulator-tools` provides. --- .github/workflows/ci-macos-ios.yml | 92 ++++++++++++++++-- docs/20-toolchains.md | 97 +++++++++++++++++++ docs/24-openkal-cross.md | 95 +++++++++++++----- docs/zh/20-toolchains.md | 85 ++++++++++++++++ docs/zh/24-openkal-cross.md | 70 ++++++++++--- examples/13-platform-targets/README.md | 81 +++++++++++++--- examples/13-platform-targets/mcpp.toml | 49 ++++++++++ src/toolchain/lifecycle.cppm | 24 ++++- src/toolchain/registry.cppm | 22 ++++- ...ws_are_wired_and_the_simulator_is_a_row.sh | 92 ++++++++++++++++++ tests/unit/test_toolchain_registry.cpp | 79 ++++++++++++++- tests/unit/test_toolchain_triple.cpp | 56 +++++++++++ 12 files changed, 778 insertions(+), 64 deletions(-) diff --git a/.github/workflows/ci-macos-ios.yml b/.github/workflows/ci-macos-ios.yml index 056ace14..eaa89ab2 100644 --- a/.github/workflows/ci-macos-ios.yml +++ b/.github/workflows/ci-macos-ios.yml @@ -263,6 +263,31 @@ jobs: [target.x86_64-ios-sim] toolchain = "llvm@22.1.8" + + # D4: THE RUNNER IS AN ARGV PREFIX AND THE SESSION BELONGS TO A + # PACKAGE. `simctl-run` comes from `xim:apple-simulator-tools`; it + # chooses a device, boots it if it is not booted, waits, spawns, and + # returns the program's own exit status. The engine learns nothing + # about simulators, which is the boundary it keeps for every other + # emulated target. + # + # The device row keeps `runner` unset: an artefact cannot be run off + # an iOS device without a signature the developer owns. + # + # THE TOOL IS DECLARED AT THE TOP LEVEL HERE AND NOT IN + # examples/13, and the difference is which hosts the manifest has to + # work on. `deps` is not conditional on a target -- measured: + # "[target.aarch64-ios-sim.xlings] does not accept 'deps'" -- and + # `xim:apple-simulator-tools` exists for macosx alone, so an + # unconditional declaration breaks a Linux build. This fixture is + # macOS-only, so it can say it. + [xlings] + deps = ["xim:apple-simulator-tools"] + TOML + cat >> /tmp/iostest/mcpp.toml << 'TOML' + + [target.aarch64-ios-sim] + runner = ["simctl-run"] TOML cat > /tmp/iostest/src/main.cpp << 'CPP' import std; @@ -319,21 +344,72 @@ jobs: echo "SPAWN-FAILED exit=$?" fi + # D4: `mcpp run` THROUGH THE RUNNER, WHICH IS THE SUPPORTED PATH. + # + # The step above spawned the artefact by hand, which proves the platform + # can run it and not that mcpp can. This repository has a standing note + # that publishing and verifying an asset is not the same as being able to + # use it through a supported path, and the difference here is a `runner` + # a manifest declares and a program a package provides. + - name: "D4: mcpp run --target aarch64-ios-sim prints 1-2-3" + continue-on-error: true + run: | + set -x + cd /tmp/iostest + out=$("$MCPP_DEV" run --target aarch64-ios-sim 2>&1) || true + set +x + printf '%s\n' "$out" | tail -20 + if printf '%s\n' "$out" | grep -q '1-2-3'; then + echo "RUN-THROUGH-RUNNER-OK" + else + echo "RUN-THROUGH-RUNNER-FAILED" + fi + + # x86_64-ios-sim IS THE THIRD ROW AND THIS RUNNER CANNOT RUN IT. + # + # The simulator runs the HOST's architecture, so an Apple-silicon machine + # has no x86_64 iOS runtime to spawn into. What can be measured here is + # the ARTEFACT, which is the claim the row's tier will rest on; a machine + # with an Intel host is what would move it further. + - name: "x86_64-ios-sim: the artefact, on a host that cannot run it" + continue-on-error: true + run: | + set -x + cd /tmp/iostest + "$MCPP_DEV" build --target x86_64-ios-sim + set +x + ART=$(find /tmp/iostest/target/x86_64-ios-sim -name iostest -type f | head -1) + file "$ART" + otool -l "$ART" | grep -A5 LC_BUILD_VERSION + # THE HOST SURFACE IS BOUNDED, AND ITS ABSENCE NAMES THE SDK. The # recorded rule is that a host dependency must be minimal, named, and # never a fallthrough -- so the refusal is a claim like any other, and # `SDKROOT` pointing at a macOS SDK is the cheapest way to make the iOS # SDK unlocatable without breaking the rest of the machine. # - # AND NO OTHER ROW CHANGES: the same environment builds for the host. - - name: "D: the refusal names the SDK, and no other row is affected" + # AND NOT BY POINTING `DEVELOPER_DIR` AT NOTHING, WHICH MEASURED NOTHING. + # + # The first version of this step set `DEVELOPER_DIR=/nonexistent` and + # expected the refusal. The build SUCCEEDED: `xcrun` ignores an invalid + # developer directory and falls back to the recorded one, so the + # environment change did not make the SDK unlocatable. The predicate was + # right and the object was wrong -- the claim was never tested. + # + # The claim now lives where the SDK is genuinely absent, which is every + # non-Apple host: `tests/e2e/641` asserts it on Linux and Windows, with + # an explicit toolchain override so the tier gate does not answer first + # and `MCPP_NO_AUTO_INSTALL=1` so no payload is needed. What is left for + # this runner is the other half of the same claim -- that a machine which + # DOES have the SDK is not refused -- and that is what the steps above + # measure by building. + - name: "D: the SDK is located here, which is the other half of the claim" continue-on-error: true run: | set -x - cd /tmp/iostest - DEVDIR=/nonexistent-developer-dir - env DEVELOPER_DIR="$DEVDIR" SDKROOT="" \ - "$MCPP_DEV" build --target aarch64-ios 2>&1 | tail -12 + xcrun --sdk iphoneos --show-sdk-path + xcrun --sdk iphonesimulator --show-sdk-path set +x - echo "--- and the host row still builds in the same environment ---" - env DEVELOPER_DIR="$DEVDIR" "$MCPP_DEV" build 2>&1 | tail -4 || true + echo "both SDKs located; the refusal for a machine without them is" + echo "asserted by tests/e2e/641 on every non-Apple host" + cd /tmp/iostest && "$MCPP_DEV" build --target aarch64-ios 2>&1 | tail -3 diff --git a/docs/20-toolchains.md b/docs/20-toolchains.md index 30906bbe..74485c00 100644 --- a/docs/20-toolchains.md +++ b/docs/20-toolchains.md @@ -595,8 +595,105 @@ an argv prefix and the session belongs to a package rather than to the engine: ```toml [target.x86_64-linux-android] runner = ["adb-run"] # a program from xim:android-platform-tools + +[target.aarch64-ios-sim] +runner = ["simctl-run"] # a program from xim:apple-simulator-tools ``` +A runner is an argv prefix and a *session* is not. Running a program on an iOS +simulator means choosing a device, booting it if it is not booted, waiting for +the boot, spawning, and returning the program's own exit status; a manifest +line has no beginning and no end, which is why that work lives in a package. + +## The Apple SDKs Are Located, Not Installed + +The three iOS rows -- `aarch64-ios`, `aarch64-ios-sim` and `x86_64-ios-sim` -- +are the other shape a platform can take, and they are worth reading beside the +two SDK toolchains above because they answer the same question differently. + +**The compiler is ours; only the SDK is Apple's.** Any sufficiently new clang +emits arm64 Mach-O for an iOS deployment target, so these rows pin +`llvm@22.1.8` -- the ordinary payload, the same one `aarch64-macos` uses. What +cannot be packaged is the iPhoneOS and iPhoneSimulator SDK: it ships inside +Xcode and is not redistributable. So mcpp **locates** it, through +`xcrun --sdk --show-sdk-path`, exactly as it has always located the +macOS SDK. + +That is why these rows carry no `sysroot` entry. That column names a package, +and a located directory is not one. + +```bash +mcpp build --target aarch64-ios # resolves llvm@22.1.8 + the iPhoneOS SDK +mcpp build --target aarch64-ios-sim # resolves llvm@22.1.8 + the Simulator SDK +``` + +### The host surface this adds, named and bounded + +Two items, both macOS-only, both in the category a proprietary runtime that +exists only on its own operating system occupies: + +| the item | reached through | the permission | +|---|---|---| +| the iPhoneOS / iPhoneSimulator SDK | `xcrun` | not redistributable; there is nothing to package | +| the simulator runtime | `simctl`, via `xim:apple-simulator-tools` | the same | + +Everything else is ecosystem: the compiler, the C++ runtime, the linker and the +packaging. Neither item is reached by a fallthrough -- each is asked for +deliberately, and its absence is a refusal that names it: + +``` +error: target aarch64-ios needs the iphoneos SDK, which this machine does not provide. + It is not redistributable, so mcpp LOCATES it rather than installing it: + `xcrun --sdk iphoneos --show-sdk-path` must answer, which needs Xcode on + macOS (not the Command Line Tools alone -- those ship the macOS SDK only). + Check `xcode-select -p`, and note that the compiler is not what is + missing: these rows pin `xim:llvm`, which every other Apple row also uses. +``` + +The refusal arrives **before** any payload is resolved. An Apple SDK is not +something a dependency can supply, so there is nothing a later step could learn +that would change the answer -- and a machine without Xcode should not download +a compiler before being told the compiler is not what is missing. + +### The deployment target + +`[build] ios_deployment_target` sits beside `macos_deployment_target`, and the +two are separate keys for one reason: `"14.0"` is a macOS version and means +nothing to an iOS SDK. Only one of them can apply to any given target, so they +share a single slot in the build fingerprint. + +```toml +[build] +ios_deployment_target = "18.0" +``` + +It is carried by the **effective triple** and nowhere else: + +``` +Target aarch64-ios → arm64-apple-ios18.0 +Target aarch64-ios-sim → arm64-apple-ios18.0-simulator +``` + +which are Apple's own spellings. No `-miphoneos-version-min` flag is emitted: +the triple fully determines the platform and the minimum, and a flag would be a +second place answering a question the triple already answers. Leaving the key +unset is legal and means the SDK's own default, which clang supplies for an +Apple target -- unlike Android, where bionic refuses an unversioned triple +outright. + +### The simulator's shape, and its boundary + +The simulator is two rows and not a flag on the device row. A simulator build +has its own SDK, produces an object naming its own platform +(`LC_BUILD_VERSION` reports `IOSSIMULATOR` rather than `IOS`), and takes a +different deployment-target segment. Both architectures exist because the +simulator runs the **host's** architecture: an Apple-silicon machine needs +`aarch64-ios-sim` and an Intel one needs `x86_64-ios-sim`. + +The device row keeps `runner` unset. An artifact cannot be run off an iOS +device without a signature the developer owns, which is not something a build +tool can supply. + ## Project-Level Version Pinning If a project needs to pin a specific version rather than rely on the global default, declare it in the project's `mcpp.toml`: diff --git a/docs/24-openkal-cross.md b/docs/24-openkal-cross.md index 6f8d92b5..9049984e 100644 --- a/docs/24-openkal-cross.md +++ b/docs/24-openkal-cross.md @@ -225,32 +225,83 @@ openkal: 1-2-3 exit 0 is the weaker claim of the two and is worth stating separately: the first says the implementation builds, the second says a program over it runs. -### iOS would share the macOS implementation, and that cannot be claimed yet - -The same argument applies on Apple's side — iOS and macOS share the Darwin -kernel, and `openkal-macos` is arch-dispatched the same way — but the argument -is not evidence. The iPhoneOS and iPhoneSimulator SDKs ship inside Xcode and -are not redistributable, so the `aarch64-ios` and `*-ios-sim` rows are -`planned`: there is nothing to build against and therefore nothing to run. -Declaring support on a structural argument alone is the shape this ecosystem has -paid for before — a package present in an index is not a package that builds a -real project — so these rows claim nothing until an SDK is reachable. - -### Web needs a new implementation, and a different one +### iOS shares the macOS implementation, and the SDK is located rather than packaged + +The same argument applies on Apple's side: iOS and macOS share the Darwin +kernel, the same call numbers and the same calling convention, and +`openkal-macos` is arch-dispatched the same way. What differs between them is +the SDK and the deployment-target flag, and both belong to the build tool +rather than to the implementation — so iOS is one `cfg` line in a manifest and +no new package. + +**What was blocked was the SDK, and what unblocked it was asking a smaller +question.** The iPhoneOS and iPhoneSimulator SDKs ship inside Xcode and are not +redistributable, which bounds *packaging* them. It does not bound *locating* +them: `aarch64-macos` has been `verified` on exactly that split since long +before these rows existed — `xim:llvm` compiles and the machine's macOS SDK is +found through `xcrun`. The iOS rows take the same split with a second SDK, so +they pin `llvm@22.1.8` and carry no `sysroot` entry, because that column names a +package and a located directory is not one. + +The consequence for this document is that the rows are no longer a structural +argument. `openkal-macos` compiles for them, and what a reader needs to know is +that the SDK is a named host dependency — one of exactly two this platform adds, +the other being `simctl` — and that its absence is a refusal naming the SDK +rather than a build that quietly produces a macOS artefact. + +### Web needed a new implementation, and `openkal-emscripten` is it Emscripten is the one of the three that changes the model rather than extending it. There is no kernel and there are no system calls to issue: Emscripten supplies its own C library over a JavaScript host. An openkal implementation for it therefore cannot be written the way `openkal-linux` is — beneath a C library -— and would have to sit **above** one. The specification permits exactly that -("an implementation may be built upon a C library, beneath one, or without -one"), so this is new software rather than a sharing decision, and it is the one -of the three that is neither done nor blocked. +— and has to sit **above** one. The specification permits exactly that ("an +implementation may be built upon a C library, beneath one, or without one"), so +this was new software rather than a sharing decision. + +`openkal-emscripten` is the first implementation in this ecosystem written in +that direction. That makes the code thin and not easy: a forward and an error +translation is most of each function, and what it has to get right is the +places where the C library's vocabulary and openkal's do **not** correspond — +the granularity that is an alignment and not a page, a monotonic clock whose +resolution a browser deliberately coarsens, a terminal that exists under node +and not in a page. + +**A partial surface is a conforming one, and the specification says how.** +Clause 6.2 gives three times, each the earliest at which the information +exists, and the implementation's three groups get three different treatments: + +| group | treatment | the reason | +|---|---|---| +| `stream`, `fs`, `time`, `env`, `memory`, `random`, `abort`, `terminal` | provided, forwarding to Emscripten's libc | MEMFS and the JavaScript host serve all of these | +| `net`, `datagram`, `timeout` | provided, with the capability word reporting what is exercisable | the calls are real and the transport is a WebSocket proxy, so `kal_net_props` claims neither IPv6 nor half-close | +| `process`, `exec`, `space` | NOT PROVIDED | there is no fork, no exec and no second address space | + +The third row is the decision worth stating plainly: **an absent symbol is the +report.** Measured: + +``` +wasm-ld: error: obj/main.o: undefined symbol: kal_process_spawn +``` -Until it exists, `wasm32-emscripten` is served the ordinary way: by a payload. -`xim:emsdk` ships the compiler, the sysroot and a libc++ module surface, so a -program that uses `import std` builds and runs for the Web today without openkal -being involved at all — which is what the row's `verified` tier records. +which is clause 6.2's second time. Providing `kal_process_spawn` so that it +returned an error would be the shape the specification forbids — present and +always failing, which the caller cannot tell from a condition — and it would +move a fact known at link time to run time. + +`openkal.task` is carried by a feature for a reason specific to this platform: +threads need `-pthread`, which selects a different C library build, a different +memory model and a different loader contract. Without the feature the +translation unit is empty and the eight symbols do not exist, which is the same +treatment the three absent interfaces get. With it they do, and +`kal_interfaces()` follows the link rather than a name the package invented. + +None of this replaces the payload route. `wasm32-emscripten` is still served +the ordinary way — `xim:emsdk` ships the compiler, the sysroot and a libc++ +module surface, so a program that uses `import std` builds and runs for the Web +with openkal not involved at all, which is what the row's `verified` tier +records. openkal is what a program uses when it wants one source above several +platform interfaces. ### The table @@ -259,9 +310,9 @@ being involved at all — which is what the row's `verified` tier records. | Linux (glibc, musl) | `openkal-linux` | the reference implementation | | Android (both ABIs) | `openkal-linux`, unchanged | builds; a program over it ran on an emulator | | macOS | `openkal-macos` | on the macOS system-call surface | -| iOS, iOS simulator | `openkal-macos` would serve it | blocked: the SDK is not redistributable | +| iOS, iOS simulator | `openkal-macos`, unchanged | Darwin is Darwin; the SDK is located, not packaged | | Windows | `openkal-windows` | on Win32 and the object manager | -| Web (Emscripten) | none | needs an implementation written ABOVE a C library | +| Web (Emscripten) | `openkal-emscripten` | written ABOVE a C library; twelve of fifteen interfaces | ## Bare Metal diff --git a/docs/zh/20-toolchains.md b/docs/zh/20-toolchains.md index 99da6a36..8391e654 100644 --- a/docs/zh/20-toolchains.md +++ b/docs/zh/20-toolchains.md @@ -542,7 +542,92 @@ Emscripten 的产物就是一个 `node` 能跑的程序。对于产物在别处 ```toml [target.x86_64-linux-android] runner = ["adb-run"] # 来自 xim:android-platform-tools 的一个程序 + +[target.aarch64-ios-sim] +runner = ["simctl-run"] # 来自 xim:apple-simulator-tools 的一个程序 +``` + +runner 是一个 argv 前缀,而一次**会话**不是。在一台 iOS 模拟器上运行一个程序意味着: +挑一台设备、若未启动则启动它、等待启动完成、spawn、并把程序自己的退出状态返回回来。 +清单里的一行没有开始也没有结束,这就是那部分工作住在一个包里的原因。 + +## Apple 的 SDK 是被定位的,不是被安装的 + +三条 iOS 行 —— `aarch64-ios`、`aarch64-ios-sim` 与 `x86_64-ios-sim` —— 是一个平台 +可以取的另一种形状,值得与上面两个 SDK 工具链放在一起读,因为它们回答的是同一个 +问题,只是答法不同。 + +**编译器是我们的;只有 SDK 是 Apple 的。** 任何足够新的 clang 都能为一个 iOS 部署 +目标产出 arm64 Mach-O,所以这三行钉的是 `llvm@22.1.8` —— 那个普通载荷,和 +`aarch64-macos` 用的是同一个。无法打包的是 iPhoneOS 与 iPhoneSimulator 的 SDK: +它在 Xcode 里且不可再分发。所以 mcpp **定位**它,通过 +`xcrun --sdk <名字> --show-sdk-path`,与它一直以来定位 macOS SDK 的方式完全相同。 + +这也是这三行不带 `sysroot` 条目的原因。那一列命名的是一个包,而一个被定位的目录 +不是包。 + +```bash +mcpp build --target aarch64-ios # 解析 llvm@22.1.8 + iPhoneOS SDK +mcpp build --target aarch64-ios-sim # 解析 llvm@22.1.8 + 模拟器 SDK +``` + +### 这条路新增的宿主面,具名且有界 + +两项,都只在 macOS 上,都落在「一个只存在于它自己那个操作系统上的专有运行时」这一类: + +| 项 | 经由 | 被允许的依据 | +|---|---|---| +| iPhoneOS / iPhoneSimulator SDK | `xcrun` | 不可再分发;没有东西可打包 | +| 模拟器运行时 | `simctl`,经由 `xim:apple-simulator-tools` | 同上 | + +其余一切都来自生态:编译器、C++ 运行时、链接器与打包。两项都不是回落到的 —— +每一项都是被有意请求的,而它的缺失是一次点名它的拒绝: + ``` +error: target aarch64-ios needs the iphoneos SDK, which this machine does not provide. + It is not redistributable, so mcpp LOCATES it rather than installing it: + `xcrun --sdk iphoneos --show-sdk-path` must answer, which needs Xcode on + macOS (not the Command Line Tools alone -- those ship the macOS SDK only). + Check `xcode-select -p`, and note that the compiler is not what is + missing: these rows pin `xim:llvm`, which every other Apple row also uses. +``` + +这次拒绝发生在**任何载荷被解析之前**。一个 Apple SDK 不是依赖能供给的东西,所以 +没有任何后续步骤能学到会改变这个答案的信息 —— 而一台没有 Xcode 的机器不应该先下载 +一个编译器,然后才被告知缺的不是编译器。 + +### 部署目标 + +`[build] ios_deployment_target` 与 `macos_deployment_target` 并列,而两者是两个键 +只有一个理由:`"14.0"` 是一个 macOS 版本号,对一个 iOS SDK 毫无意义。任何一个目标 +上两者只有一个能成立,所以它们共用构建指纹里的同一个槽。 + +```toml +[build] +ios_deployment_target = "18.0" +``` + +它由**有效三元组**承载,别处都不承载: + +``` +Target aarch64-ios → arm64-apple-ios18.0 +Target aarch64-ios-sim → arm64-apple-ios18.0-simulator +``` + +这是 Apple 自己的拼法。不发 `-miphoneos-version-min` 标志:三元组已经完全决定了 +平台与最低版本,而一个标志会成为第二个回答三元组已经回答过的问题的地方。不写这个键 +是合法的,含义是 SDK 自己的默认值 —— 对一个 Apple 目标,clang 会供给它 —— +这与 Android 不同,那里 bionic 会直接拒绝一个不带版本的三元组。 + +### 模拟器的形状与它的边界 + +模拟器是两行,而不是设备行上的一个标志。一次模拟器构建有它自己的 SDK,产出的对象 +命名它自己的平台(`LC_BUILD_VERSION` 报告 `IOSSIMULATOR` 而不是 `IOS`),并取一个 +不同的部署目标段。两个架构都存在,因为模拟器跑的是**宿主**的架构:一台 Apple 芯片 +的机器需要 `aarch64-ios-sim`,一台 Intel 的需要 `x86_64-ios-sim`。 + +设备行的 `runner` 保持未设。没有开发者自己拥有的签名,一个产物无法在一台 iOS 设备 +上被运行,而那不是一个构建工具能供给的东西。 ## 项目级版本锁定 diff --git a/docs/zh/24-openkal-cross.md b/docs/zh/24-openkal-cross.md index b5dcffa9..f82f7c0d 100644 --- a/docs/zh/24-openkal-cross.md +++ b/docs/zh/24-openkal-cross.md @@ -196,27 +196,67 @@ openkal: 1-2-3 exit 0 `openkal-linux` 自己也能为两个 Android 目标原样编译,这是两条里较弱的那一条,值得 分开陈述:前者说的是**实现**构建得起来,后者说的是**它上面的程序**跑得起来。 -### iOS 会共用 macOS 的实现,而这一条现在还不能声称 +### iOS 共用 macOS 的实现,而 SDK 是被定位的而不是被打包的 -同样的论证在 Apple 这一侧成立 —— iOS 与 macOS 共用 Darwin 内核,而 `openkal-macos` -是按同样方式按架构分支的 —— 但**论证不是证据**。iPhoneOS 与 iPhoneSimulator 的 SDK -在 Xcode 里且不可再分发,所以 `aarch64-ios` 与 `*-ios-sim` 三行是 `planned`:没有 -东西可以拿来构建,因此也没有东西可以拿来运行。仅凭一个结构性论证就声明支持,是这个 -生态已经付过代价的那种形状 —— 一个在索引里的包不等于一个能构建真实工程的包 —— -所以在 SDK 可达之前,这三行什么都不声称。 +同样的论证在 Apple 这一侧成立:iOS 与 macOS 共用 Darwin 内核、同一套调用号、同一 +套调用约定,而 `openkal-macos` 是按同样方式按架构分支的。两者之间不同的是 SDK 与 +部署目标标志,而这两样都属于构建工具而不属于实现 —— 所以 iOS 是清单里的一行 `cfg`, +不是一个新包。 -### Web 需要一份新的实现,而且是另一种形状 +**被阻塞的是 SDK,而解开它的是把问题问得更小。** iPhoneOS 与 iPhoneSimulator 的 +SDK 在 Xcode 里且不可再分发,这界定的是**打包**它们。它没有界定**定位**它们: +`aarch64-macos` 早在这三行存在之前就以完全相同的切分方式是 `verified` —— `xim:llvm` +负责编译,机器自己的 macOS SDK 通过 `xcrun` 被找到。iOS 三行取的是同一种切分加上 +第二个 SDK,所以它们钉 `llvm@22.1.8` 且不带 `sysroot` 条目:那一列命名的是一个包, +而一个被定位的目录不是包。 + +对这份文档的后果是,这三行不再是一个结构性论证。`openkal-macos` 能为它们编译,而 +读者需要知道的是:SDK 是一个具名的宿主依赖 —— 这个平台恰好新增两个,另一个是 +`simctl` —— 并且它的缺失是一次点名 SDK 的拒绝,而不是一次悄悄产出 macOS 产物的 +构建。 + +### Web 需要过一份新的实现,而 `openkal-emscripten` 就是它 Emscripten 是三者里**改变模型**而不是扩展表格的那一个。那里没有内核,也没有系统 调用可发:Emscripten 在一个 JavaScript 宿主之上供给它自己的 C 库。所以给它写的 openkal 实现不可能按 `openkal-linux` 的方式写 —— 落在一个 C 库底下 —— 而必须落在 一个 C 库**之上**。规范恰好允许这一点(「一个实现可以建立在一个 C 库之上、之下, -或者不依赖 C 库」),所以这是**新软件**而不是一个共用决定,而它是三者里既没做完也 -没被阻塞的那一个。 +或者不依赖 C 库」),所以这是**新软件**而不是一个共用决定。 + +`openkal-emscripten` 是这个生态里第一份按那个方向写的实现。那个方向让代码变薄而 +不是变容易:每个函数大体是一次转发加一次错误翻译,而它必须做对的地方,恰好是 C 库 +的词汇与 openkal 的词汇**不对应**的那些地方 —— 一个是对齐而不是页的粒度、一个分辨 +率被浏览器有意变粗的单调时钟、一个在 node 下存在而在页面里不存在的终端。 + +**一个不完整的面也可以是一个符合规范的面,而规范写明了怎么做。** 6.2 条给出三个 +时刻,每一个都是相应信息最早存在的时刻,而这份实现的三组各取一种处理: + +| 组 | 处理 | 理由 | +|---|---|---| +| `stream`、`fs`、`time`、`env`、`memory`、`random`、`abort`、`terminal` | 提供,转发到 Emscripten 的 libc | MEMFS 与 JavaScript 宿主服务了其中每一项 | +| `net`、`datagram`、`timeout` | 提供,由能力字报告哪些可被实际运用 | 调用是真的,而承载它们的是一个 WebSocket 代理,所以 `kal_net_props` 既不声称 IPv6 也不声称半关闭 | +| `process`、`exec`、`space` | 不提供 | 那里没有 fork、没有 exec,也没有第二个地址空间 | + +第三行是值得直说的那个决定:**一个缺席的符号就是那份报告。** 实测: + +``` +wasm-ld: error: obj/main.o: undefined symbol: kal_process_spawn +``` + +这正是 6.2 条的第二个时刻。提供一个返回错误的 `kal_process_spawn` 会是规范禁止的 +那种形状 —— 存在而永远失败,而调用方无法把它与一个条件区分开 —— 并且会把一个在 +链接期已知的事实挪到运行期。 + +`openkal.task` 由一个 feature 承载,理由是这个平台特有的:线程需要 `-pthread`, +而那个开关选定的是另一份 C 库构建、另一个内存模型和另一套加载器契约。不带这个 +feature 时,那个翻译单元是空的,八个符号不存在 —— 与上面三个缺席接口同一种处理。 +带上它时它们存在,而 `kal_interfaces()` 跟随链接本身,而不是跟随一个由包自己发明 +的名字。 -在它出现之前,`wasm32-emscripten` 走的是普通那条路:一个载荷。`xim:emsdk` 自带 -编译器、sysroot 和一份 libc++ 的模块面,所以一个用 `import std` 的程序今天就能为 -Web 构建并运行,而 openkal 完全不参与 —— 这正是那一行的 `verified` 层级所记录的。 +以上都不取代载荷那条路。`wasm32-emscripten` 仍然走普通那条路 —— `xim:emsdk` 自带 +编译器、sysroot 和一份 libc++ 的模块面,所以一个用 `import std` 的程序就能为 Web +构建并运行,而 openkal 完全不参与,这正是那一行的 `verified` 层级所记录的。openkal +是一个程序想让一份源码落在若干平台接口之上时才会用到的东西。 ### 表 @@ -225,9 +265,9 @@ Web 构建并运行,而 openkal 完全不参与 —— 这正是那一行的 `ve | Linux(glibc、musl) | `openkal-linux` | 参考实现 | | Android(两个 ABI) | `openkal-linux`,原样 | 构建通过;它上面的程序在模拟器上跑过 | | macOS | `openkal-macos` | 在 macOS 的系统调用面上 | -| iOS、iOS 模拟器 | `openkal-macos` 会服务它 | 阻塞:SDK 不可再分发 | +| iOS、iOS 模拟器 | `openkal-macos`,原样 | Darwin 就是 Darwin;SDK 被定位而不是被打包 | | Windows | `openkal-windows` | 在 Win32 与对象管理器上 | -| Web(Emscripten) | 无 | 需要一份写在 C 库**之上**的实现 | +| Web(Emscripten) | `openkal-emscripten` | 写在 C 库**之上**;十五个接口里的十二个 | ## 裸机 diff --git a/examples/13-platform-targets/README.md b/examples/13-platform-targets/README.md index 89bd7045..6575bb14 100644 --- a/examples/13-platform-targets/README.md +++ b/examples/13-platform-targets/README.md @@ -96,21 +96,78 @@ mcpp build --target aarch64-linux-android # [target.…] toolchain = "llvm@22 ## iOS -`aarch64-ios`、`aarch64-ios-sim`、`x86_64-ios-sim` 三行在词汇里,都是 `planned`: +三行:`aarch64-ios`(真机)、`aarch64-ios-sim` 与 `x86_64-ios-sim`(模拟器)。 ```bash -mcpp build --target aarch64-ios-sim -# error: target 'aarch64-ios-sim' is registered but not yet supported (planned) -# — no toolchain is published for it yet. +mcpp build --target aarch64-ios # 真机产物 +mcpp run --target aarch64-ios-sim # 模拟器,经由 runner ``` -阻塞项是**许可**而不是载荷:NDK 是 Apache-2.0、Emscripten 是 MIT,而 iPhoneOS 与 -iPhoneSimulator 的 SDK 在 Xcode 里,两者都不可再分发。这三行今天买到的是一句 -点名那一行的 `tier-planned`,而不是一句假的 `unknown target`。 +**编译器是生态的,只有 SDK 是 Apple 的。** 这三行钉 `llvm@22.1.8` —— 和 +`aarch64-macos` 用的是同一个普通载荷。任何足够新的 clang 都能为一个 iOS 部署目标 +产出 arm64 Mach-O;不可打包的是 iPhoneOS 与 iPhoneSimulator 的 SDK,它在 Xcode 里 +且不可再分发。所以 mcpp **定位**它,经由 `xcrun --sdk <名字> --show-sdk-path`,与 +它一直以来定位 macOS SDK 的方式完全相同。 + +实测(macos-15,Xcode 16.4,iPhoneOS/iPhoneSimulator 18.5, +`ios_deployment_target = "18.0"`): + +``` +aarch64-ios -> Mach-O 64-bit executable arm64 + LC_BUILD_VERSION platform 2 (IOS) minos 18.0 +aarch64-ios-sim -> Mach-O 64-bit executable arm64 + LC_BUILD_VERSION platform 7 (IOSSIMULATOR) minos 18.0 +mcpp run --target aarch64-ios-sim -> 1-2-3 +``` + +`platform 2` 与 `platform 7` 是这里唯一值得盯住的读数:一次构建成功分不开这两者, +而一个在设备行上报告 `IOSSIMULATOR` 的产物是一个没有任何后续步骤会拒绝的错误产物。 + +### 部署目标与 runner + +```toml +[build] +ios_deployment_target = "18.0" + +[target.aarch64-ios-sim] +runner = ["simctl-run"] +``` + +部署目标由**有效三元组**承载,别处都不承载 —— `arm64-apple-ios18.0` 与 +`arm64-apple-ios18.0-simulator`,这是 Apple 自己的拼法。不发 +`-miphoneos-version-min`:三元组已经说过了,而一个标志会成为第二个说它的地方。 + +`simctl-run` 来自 `xim:apple-simulator-tools`,需要先装: + +```bash +xlings install apple-simulator-tools +``` + +这一步没有写进清单,而这是一处**限制**而不是一个选择:`deps` 不按目标条件化, +而把它写在顶层会让这个例子的 Linux 构建依赖一个只为 macOS 存在的包 —— 两条都实测 +过,`mcpp.toml` 里记着那两条消息。 + +runner 是一个 argv 前缀,而一次**会话**不是:挑一台设备、启动、等待、spawn、把程序 +自己的退出状态返回 —— 清单里的一行没有开始也没有结束,这就是那部分知识住在一个包里 +而不是住在引擎里的原因。 + +设备行的 `runner` 保持未设:没有开发者自己拥有的签名,一个产物无法在一台 iOS 设备 +上被运行。 + +### 这台机器上没有 SDK 时 + +缺失是一次**点名那个 SDK 的拒绝**,而且它发生在任何载荷被解析之前 —— 一台没有 +Xcode 的机器不应该先下载一个编译器,然后才被告知缺的不是编译器: + +``` +error: target aarch64-ios needs the iphoneos SDK, which this machine does not provide. + ... `xcrun --sdk iphoneos --show-sdk-path` must answer, which needs Xcode on + macOS (not the Command Line Tools alone -- those ship the macOS SDK only). +``` 模拟器是**一个目标**而不是一个 runner:它有自己的 SDK、产出自己的对象,取 -`-mios-simulator-version-min` 而真机取 `-miphoneos-version-min`。所以它有自己的 -行,而不是折进设备那一行。 +`-mios-simulator-version-min` 那一段而真机取 `-miphoneos-version-min` 那一段。 +两个架构都在,因为模拟器跑的是**宿主**的架构。 ## 支持矩阵 @@ -120,5 +177,7 @@ iPhoneSimulator 的 SDK 在 Xcode 里,两者都不可再分发。这三行今 |---|---|---|---| | `wasm32-emscripten` | verified | `emsdk@6.0.9` | 是,`node` | | `x86_64-linux-android` | verified | `android-ndk@30.0.16248370` | 是,平台模拟器 | -| `aarch64-linux-android` | preview | `android-ndk@30.0.16248370` | 否 —— 从 x86_64 宿主没有执行路径 | -| `aarch64-ios` / `*-ios-sim` | planned | — | 否 | +| `aarch64-linux-android` | verified | `android-ndk@30.0.16248370` | 是,`qemu-aarch64-static` + 从镜像取出的 bionic | +| `aarch64-ios` | planned | `llvm@22.1.8` | 否 —— 真机需要开发者自己的签名 | +| `aarch64-ios-sim` | planned | `llvm@22.1.8` | 是,`simctl-run`(macos-15) | +| `x86_64-ios-sim` | planned | `llvm@22.1.8` | 否 —— 模拟器跑宿主架构,而那台宿主是 arm64 | diff --git a/examples/13-platform-targets/mcpp.toml b/examples/13-platform-targets/mcpp.toml index ab53ffd9..09ac4a09 100644 --- a/examples/13-platform-targets/mcpp.toml +++ b/examples/13-platform-targets/mcpp.toml @@ -19,3 +19,52 @@ min_api_level = 24 [target.x86_64-linux-android] min_api_level = 24 + +# APPLE'S SECOND PLATFORM, AND ITS OWN VERSION SPACE. `"18.0"` is an iOS +# version; `macos_deployment_target` takes a macOS one, and `"14.0"` means +# nothing to an iOS SDK. A target is one platform or the other, so the two keys +# share a single slot in the build fingerprint. +# +# It is carried by the EFFECTIVE TRIPLE and nowhere else -- +# `arm64-apple-ios18.0` and `arm64-apple-ios18.0-simulator`, Apple's own +# spellings. No `-miphoneos-version-min` flag is emitted: the triple fully +# determines the platform and the minimum, and a flag would be a second place +# answering a question the triple already answers. +[build] +ios_deployment_target = "18.0" + +# THE SIMULATOR'S SESSION BELONGS TO A PACKAGE. `runner` is an argv prefix, so +# `mcpp run --target aarch64-ios-sim` executes `simctl-run `; the +# program chooses a device, boots it if it is not booted, waits for the boot, +# spawns, and returns the program's own exit status. A manifest line has no +# beginning and no end, which is why that work is not written here. +# +# The DEVICE row keeps `runner` unset. An artifact cannot be run off an iOS +# device without a signature the developer owns. +[target.aarch64-ios-sim] +runner = ["simctl-run"] + +[target.x86_64-ios-sim] +runner = ["simctl-run"] + +# THE RUNNER'S PROGRAM IS NOT DECLARED HERE, AND THAT IS A LIMITATION RATHER +# THAN A CHOICE. +# +# `simctl-run` comes from `xim:apple-simulator-tools`, and the natural place to +# say so is beside the row that uses it. That does not exist: `deps` is not +# conditional on a target, and declaring it at the top level breaks every other +# row on every other host. Both measured: +# +# error: [target.aarch64-ios-sim.xlings] does not accept 'deps'. Only +# `workspace` is conditional on a target +# error: package 'xim:apple-simulator-tools' has no build for linux +# (available on: macosx) +# +# The second is the sharper one: this example exists to build for several +# platforms from one source, and an unconditional tool declaration makes the +# Linux build depend on a package that only exists for macOS. +# +# So the program is installed by whoever runs the simulator rows -- +# `xlings install apple-simulator-tools` -- and mcpp's runner lookup finds it +# on PATH. See the README. A per-target tool axis would remove the step, and it +# is recorded rather than worked around. diff --git a/src/toolchain/lifecycle.cppm b/src/toolchain/lifecycle.cppm index d293b5c2..c8a21a02 100644 --- a/src/toolchain/lifecycle.cppm +++ b/src/toolchain/lifecycle.cppm @@ -980,11 +980,29 @@ export int toolchain_install(const mcpp::config::GlobalConfig& cfg, return 0; } - auto bin = mcpp::toolchain::toolchain_frontend(payload->binDir, pkg); - if (!std::filesystem::exists(bin)) { + // `payload_frontend` AND NOT `toolchain_frontend(payload->binDir, ...)`. + // + // This was one of the five sites the `frontendSubdir` note in + // mcpp.toolchain.registry records: a caller that composes + // `/bin` itself cannot see where the package says its compiler + // is. Four were repaired and this one was not, so + // `mcpp toolchain install emsdk 6.0.9` fetched the archive correctly + // and then looked for `clang++` in `bin/` -- while `em++` is in + // `emscripten/`. + // + // It also picks up the payload's own descriptor, which is the whole + // point of having one: the install path and the build path now ask the + // same function where the compiler is, so they cannot disagree. + auto binR = mcpp::toolchain::payload_frontend(payload->root, pkg); + if (!binR) { + mcpp::ui::error(binR.error()); + return 1; + } + auto bin = *binR; + if (bin.empty() || !std::filesystem::exists(bin)) { mcpp::ui::error(std::format( "installed package has no known C++ frontend in '{}'", - payload->binDir.string())); + mcpp::toolchain::payload_frontend_dir(payload->root, pkg).string())); return 1; } diff --git a/src/toolchain/registry.cppm b/src/toolchain/registry.cppm index 22112908..328674d2 100644 --- a/src/toolchain/registry.cppm +++ b/src/toolchain/registry.cppm @@ -673,7 +673,25 @@ XimToolchainPackage to_xim_package(const ToolchainSpec& spec) { // no fourth value is invented; what changes is which package answers. const auto& lt = spec.target; - if (lt.os == "emscripten") { + // AND THE PAYLOAD THE SPEC NAMED DECIDES BEFORE THE TARGET DOES. + // + // `emsdk@6.0.9` names a payload. The target is the OTHER way to reach + // the same answer -- `--target wasm32-emscripten` with nothing + // declared -- and reading only the target meant a spec that named the + // payload and no target resolved the generic llvm shape: + // + // $ mcpp toolchain install emsdk 6.0.9 + // error: installed package has no known C++ frontend in + // '.../xim-x-emsdk/6.0.9/bin' + // + // The archive was fetched correctly and then looked for `clang++` in + // `bin/`, because `frontendSubdir` had been decided by a target that + // was not given. `payloadName` is the field `parse_toolchain_spec` + // fills from the spelling, and it is the more direct statement of the + // two: a spec that names a payload has answered this question. + const std::string_view named = spec.payloadName; + + if (named == "emsdk" || lt.os == "emscripten") { // `em++` is a `#!/bin/sh` wrapper beside the Python it execs, in // `emscripten/` rather than `bin/` -- `bin/` holds the raw clang, // which would compile for wasm and then link like an ordinary @@ -685,7 +703,7 @@ XimToolchainPackage to_xim_package(const ToolchainSpec& spec) { return pkg; } - if (lt.is_android()) { + if (named == "android-ndk" || lt.is_android()) { // One NDK payload serves every Android arch and API level: the // arch arrives as `--target=-linux-android` on the // command line, not as a different package. The host tuple in the diff --git a/tests/e2e/641_the_android_rows_are_wired_and_the_simulator_is_a_row.sh b/tests/e2e/641_the_android_rows_are_wired_and_the_simulator_is_a_row.sh index 9f8801a4..f8f42d21 100755 --- a/tests/e2e/641_the_android_rows_are_wired_and_the_simulator_is_a_row.sh +++ b/tests/e2e/641_the_android_rows_are_wired_and_the_simulator_is_a_row.sh @@ -131,5 +131,97 @@ else echo " ok: min_api_level = 24 is accepted by the manifest" fi +# 7. THE iOS ROWS NAME THEIR PAYLOAD, AND IT IS A CONVENTION PIN. The compiler +# is ours and only the SDK is Apple's: `xim:llvm` emits arm64 Mach-O for an +# iOS deployment target, so the rows pin it exactly as the wasm row pins +# emsdk. A capability pin would be wrong here -- `aarch64-macos` has the +# same constraint (Darwin needs clang) and is not a capability row -- so the +# pin is overridable, which case 8 relies on. +list=$( "$MCPP" toolchain list --format json 2>/dev/null ) +for target in aarch64-ios aarch64-ios-sim x86_64-ios-sim; do + if tr ',' '\n' <<<"$list" | grep -A6 "\"target\": *\"$target\"" \ + | grep -q "llvm"; then + echo " ok: $target names the llvm payload" + else + echo "FAIL: $target does not name llvm in toolchain list" + tr ',' '\n' <<<"$list" | grep -A6 "\"target\": *\"$target\"" | sed 's/^/ /' + fail=1 + fi +done + +# 8. AND THE SDK IS LOCATED, SO ITS ABSENCE IS A REFUSAL THAT NAMES IT. +# +# THIS CLAIM IS HOST-SHAPED AND BOTH ARMS ARE REAL. The iPhoneOS and +# iPhoneSimulator SDKs ship inside Xcode and exist on no other system, so on +# a non-Apple host the refusal must arrive and on macOS it must not. A +# single-armed check would be a skip on one of them. +# +# IT WAS FIRST WRITTEN AS A macOS CI STEP THAT POINTED `DEVELOPER_DIR` AT A +# NONEXISTENT DIRECTORY, and that measured nothing: the build SUCCEEDED, +# because `xcrun` ignores an invalid developer directory and falls back to +# the recorded one. The predicate was right and the object was wrong. +# +# No payload is needed: the SDK is located before the toolchain is resolved, +# which is itself a property worth asserting -- a machine without Xcode used +# to download a 700 MB compiler before being told the compiler was not what +# was missing. Hence MCPP_NO_AUTO_INSTALL=1 and the explicit override, which +# opens the tier gate so that this gate is the one that answers. +for target in aarch64-ios aarch64-ios-sim; do + d="$t/sdk-$target" + pkg "$d" "" "[target.$target]" 'toolchain = "llvm@22.1.8"' + out=$( cd "$d" && MCPP_NO_AUTO_INSTALL=1 "$MCPP" build --target "$target" 2>&1 ) || true + case "$(uname -s)" in + Darwin) + if grep -q "needs the .* SDK" <<<"$out"; then + echo "FAIL: $target refused for a missing SDK on a machine that has one" + fail=1 + else + echo " ok: $target is not refused for its SDK on macOS" + fi + ;; + *) + if grep -q "needs the .* SDK, which this machine does not provide" <<<"$out"; then + # AND THE MESSAGE CARRIES WHAT A READER ACTS ON: which SDK, the + # command that must answer, and that the compiler is not the thing + # missing. A refusal that names none of those sends the reader to + # the documentation. + miss="" + grep -q "xcrun --sdk" <<<"$out" || miss="$miss the-xcrun-command" + grep -q "Command Line Tools" <<<"$out" || miss="$miss the-CLT-note" + grep -q "xim:llvm" <<<"$out" || miss="$miss the-compiler-note" + if [ -n "$miss" ]; then + echo "FAIL: $target's refusal omits:$miss" + fail=1 + else + echo " ok: $target is refused naming the SDK and what to do" + fi + # AND IT ARRIVES BEFORE THE PAYLOAD IS RESOLVED. + if grep -q "Resolved llvm@" <<<"$out"; then + echo "FAIL: $target resolved a payload before refusing for the SDK" + fail=1 + fi + else + echo "FAIL: $target was not refused for its SDK on a non-Apple host" + grep -m3 -E "^error|^ *Resolved" <<<"$out" | sed 's/^/ /' + fail=1 + fi + ;; + esac +done + +# THE EFFECTIVE TRIPLE IS NOT ASSERTED HERE, AND THE REASON IS CASE 8. +# +# `arm64-apple-ios18.0` and `arm64-apple-ios18.0-simulator` are the one place +# the iOS deployment target is said -- `-miphoneos-version-min` is deliberately +# not emitted, because a flag would be a second place answering the same +# question. That decision needs a criterion of its own or it disappears when +# the thing it was folded into ships. +# +# It cannot be that criterion HERE: the SDK gate above refuses before the +# toolchain is resolved, so the `Target X -> Y` line is never printed on a host +# without Xcode. The claim therefore lives in +# tests/unit/test_toolchain_triple.cpp, where `llvm_triple` is asked directly +# and every host can ask it. + if [ "$fail" -ne 0 ]; then echo "FAIL: 641"; exit 1; fi echo "PASS: 641" diff --git a/tests/unit/test_toolchain_registry.cpp b/tests/unit/test_toolchain_registry.cpp index 6d4ce699..713db3c7 100644 --- a/tests/unit/test_toolchain_registry.cpp +++ b/tests/unit/test_toolchain_registry.cpp @@ -367,9 +367,19 @@ TEST(ToolchainSysrootDeps, OneDerivationForTheGlibcSysrootPayloads) { // what changes is which package answers and where its driver lives. TEST(SdkPayloads, TheTargetChoosesThePackageAndThePackageKnowsItsLayout) { auto pkg_for = [](std::string_view target) { - auto spec = mcpp::toolchain::parse_toolchain_spec("emsdk@6.0.9"); - // The spec's own target is replaced, because the NDK case must be - // reachable from the same family with a different triple. + // A SPELLING THAT NAMES NO PAYLOAD, WHICH IS WHAT THIS TEST IS ABOUT. + // + // This fixture built every case from `emsdk@6.0.9` and replaced only + // the target, which worked while the target was the ONLY thing read -- + // and then `payloadName` began to decide first (correctly: a spec that + // names a payload has answered this question), and the Android cases + // resolved the emsdk payload from a spelling that says emsdk. + // + // The fixture had picked its object by convenience. `llvm@22.1.8` + // names the family and no payload, which is the shape a build has when + // it passes `--target` and declares nothing -- the shape whose + // resolution this test exists to state. + auto spec = mcpp::toolchain::parse_toolchain_spec("llvm@22.1.8"); auto s = *spec; if (auto t = mcpp::toolchain::triple::parse(target)) s.target = *t; return mcpp::toolchain::to_xim_package(s); @@ -493,6 +503,69 @@ TEST(SdkPayloads, TheDisplayNamesThePayloadAndNotOnlyTheFamily) { } } +// ─── A spec that names a payload has answered which payload ─────────────── +// +// `to_xim_package` decided the payload from the TARGET, which is right for the +// spelling a build uses -- `--target wasm32-emscripten` with nothing declared +// -- and wrong for the spelling an install uses, where there is no target at +// all. Measured: +// +// $ mcpp toolchain install emsdk 6.0.9 +// error: installed package has no known C++ frontend in +// '.../xim-x-emsdk/6.0.9/bin' +// +// The archive was fetched correctly and then searched for `clang++` in `bin/`, +// because `frontendSubdir` had been decided by a target nobody gave. Both SDK +// payloads had it; neither `mcpp toolchain install emsdk` nor +// `mcpp toolchain install android-ndk` had ever worked. +// +// THE DENOMINATOR IS BOTH PAYLOADS AND BOTH SPELLINGS, because the defect is +// in the AXIS and not in one row: a payload named with a target, and the same +// payload named without one, must answer identically. +TEST(SdkPayloads, ThePayloadTheSpecNamedDecidesWithoutATarget) { + struct Case { + std::string_view spelling, ximName, subdir, frontend, target; + }; + const Case cases[] = { + { "emsdk@6.0.9", "emsdk", "emscripten", "em++", "" }, + { "emsdk@6.0.9", "emsdk", "emscripten", "em++", "wasm32-emscripten" }, + { "android-ndk@30.0.16248370", "android-ndk", + "toolchains/llvm/prebuilt", "clang++", "" }, + { "android-ndk@30.0.16248370", "android-ndk", + "toolchains/llvm/prebuilt", "clang++", "aarch64-linux-android" }, + }; + for (auto const& c : cases) { + auto spec = mcpp::toolchain::parse_toolchain_spec(std::string(c.spelling)); + ASSERT_TRUE(spec.has_value()) << c.spelling; + if (!c.target.empty()) + if (auto t = mcpp::toolchain::triple::parse(c.target)) spec->target = *t; + auto pkg = mcpp::toolchain::to_xim_package(*spec); + const std::string where = std::string(c.spelling) + " target='" + + std::string(c.target) + "'"; + EXPECT_EQ(pkg.ximName, c.ximName) << where; + EXPECT_NE(pkg.frontendSubdir.find(c.subdir), std::string::npos) + << where << " subdir=" << pkg.frontendSubdir; + // The candidate list names the payload's own driver rather than the + // family's, which is the half that sent the install looking for + // `clang++` in the emsdk payload. + bool named = false; + for (auto const& cand : pkg.frontendCandidates) + if (cand.find(c.frontend) != std::string::npos) named = true; + EXPECT_TRUE(named) << where << " candidates do not name " << c.frontend; + } + + // AND A SPEC THAT NAMES NO PAYLOAD STILL READS THE TARGET. This is the + // other half of the axis: `llvm@22.1.8` with an Android target is the + // escape-hatch spelling, and it must resolve the generic llvm payload + // rather than the NDK -- the capability gate is what refuses it, and a + // gate cannot refuse what was silently rewritten. + auto plain = mcpp::toolchain::parse_toolchain_spec("llvm@22.1.8"); + ASSERT_TRUE(plain.has_value()); + EXPECT_TRUE(plain->payloadName.empty()); + if (auto t = mcpp::toolchain::triple::parse("aarch64-macos")) plain->target = *t; + EXPECT_EQ(mcpp::toolchain::to_xim_package(*plain).ximName, "llvm"); +} + // ─── The C compiler beside a C++ one ─────────────────────────────────────── // // Every frontend this engine can resolve, and the C driver beside it. The diff --git a/tests/unit/test_toolchain_triple.cpp b/tests/unit/test_toolchain_triple.cpp index d3dd0095..a7dabb59 100644 --- a/tests/unit/test_toolchain_triple.cpp +++ b/tests/unit/test_toolchain_triple.cpp @@ -766,6 +766,62 @@ TEST(Triple, EachRowsTierMatchesTheEvidenceThatExistsForIt) { } } +// ─── The iOS deployment target is said in exactly one place ──────────────── +// +// `-miphoneos-version-min` and `-mios-simulator-version-min` are deliberately +// NOT emitted. The effective triple carries the version -- Apple's own +// spelling -- and it reaches both the compile and the link line through +// `crossTargetFlag`, so a flag would be a second place answering a question +// the triple already answers. macOS keeps its flag because its own triple is +// also versioned and the flag was documented as insurance against environment +// propagation; adding the iOS equivalent would make two mechanisms out of one. +// +// THIS IS THAT DECISION'S OWN CRITERION, and it needs one: a requirement +// folded into another fix disappears when that fix ships, and nothing else in +// this tree would notice the version silently leaving the triple. It cannot be +// an end-to-end criterion either -- the SDK gate refuses before the toolchain +// is resolved, so a host without Xcode never prints the effective triple. +TEST(Triple, TheIosDeploymentTargetIsCarriedByTheEffectiveTripleAlone) { + struct Case { std::string_view canonical, unversioned, versioned; }; + const Case cases[] = { + { "aarch64-ios", "arm64-apple-ios", + "arm64-apple-ios18.0" }, + { "aarch64-ios-sim", "arm64-apple-ios-simulator", + "arm64-apple-ios18.0-simulator" }, + { "x86_64-ios-sim", "x86_64-apple-ios-simulator", + "x86_64-apple-ios18.0-simulator" }, + }; + for (auto const& c : cases) { + auto t = parse(c.canonical); + ASSERT_TRUE(t.has_value()) << c.canonical; + EXPECT_EQ(t->llvm_triple("18.0"), c.versioned) << c.canonical; + // AND AN UNSTATED VERSION LEAVES THE SEGMENT ALONE rather than + // inventing one. macOS bakes in a floor because its static libc++ + // archives have one; iOS takes libc++ from the located SDK, so an + // unstated version means the SDK's own default -- which clang supplies + // for an Apple target and bionic famously does not. + EXPECT_EQ(t->llvm_triple(), c.unversioned) << c.canonical; + + // THE `-simulator` SUFFIX COMES AFTER THE VERSION, which is Apple's + // order and not the other one. `arm64-apple-ios-simulator18.0` is not + // a triple clang accepts, and the two spellings differ only in where + // four characters sit. + EXPECT_TRUE(t->is_ios()) << c.canonical; + EXPECT_EQ(t->is_ios_simulator(), + c.canonical.find("-sim") != std::string_view::npos) + << c.canonical; + } + + // AND THE DEVICE TRIPLE IS NOT A PREFIX-MATCH AWAY FROM THE SIMULATOR'S. + // Anything that compared them by prefix would treat a simulator build as a + // device build, which produces an artefact for the wrong platform that no + // later step refuses. + auto dev = parse("aarch64-ios"); + auto sim = parse("aarch64-ios-sim"); + ASSERT_TRUE(dev.has_value() && sim.has_value()); + EXPECT_NE(dev->llvm_triple("18.0"), sim->llvm_triple("18.0")); +} + // A CAPABILITY PIN CANNOT BE OVERRIDDEN; A CONVENTION PIN CAN. // // Asserted exhaustively over the table rather than on examples, because the From 43a476323a60e967ca1b412816bac11372cdcb7b Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:42:30 +0800 Subject: [PATCH 07/14] fix(runner): a package's program may sit at the payload root, and two already do D4's LEG WAS RED AND THE PACKAGE WAS INSTALLED CORRECTLY. Measured on a macos-15 runner with `xim:apple-simulator-tools` declared and present: error: runner 'simctl-run' for 'aarch64-ios-sim' was not found on any search path. Searched: .../xim-x-apple-simulator-tools/0.1.0/bin The directory searched was right and the program was one level up. `bin/` is the convention and stays FIRST; it is not universal -- `xim:7zip` puts `7zz` straight into its install directory, and so did the first version of `xim:apple-simulator-tools`. Two directories per declared package is cheaper than a rule every recipe has to know, and a recipe that does use `bin/` is unaffected because that entry is still tried first. The recipe moves too (openxlings/xim-pkgindex#820, merged): a package whose whole purpose is to provide a program puts it where consumers look, and its `xvm` registration moves with it -- a recipe that wrote into `bin/` and registered the root would install without complaint and produce a shim resolving to nothing. And the fixture uses `[xlings.workspace]`, which is what mcpp asks for: "deps is superseded by [xlings.workspace] and will stop being read". WHAT THE macOS RUN MEASURED FOR ALL THREE ROWS, `ios_deployment_target = "18.0"`, Xcode 16.4, SDKs 18.5: aarch64-ios arm64 LC_BUILD_VERSION platform 2 (IOS) minos 18.0 aarch64-ios-sim arm64 LC_BUILD_VERSION platform 7 (IOSSIMULATOR) minos 18.0 x86_64-ios-sim x86_64 LC_BUILD_VERSION platform 7 (IOSSIMULATOR) minos 18.0 `platform 2` against `platform 7` is the pair worth having a reading for: a successful build cannot tell them apart, and an artefact reporting IOSSIMULATOR from the device row is one no later step refuses. The simulator artefact ran under `simctl spawn` and printed `1-2-3`. --- .github/workflows/ci-macos-ios.yml | 14 +++++++++----- src/build/prepare.cppm | 23 ++++++++++++++++++++++- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci-macos-ios.yml b/.github/workflows/ci-macos-ios.yml index eaa89ab2..48674f5a 100644 --- a/.github/workflows/ci-macos-ios.yml +++ b/.github/workflows/ci-macos-ios.yml @@ -276,13 +276,17 @@ jobs: # # THE TOOL IS DECLARED AT THE TOP LEVEL HERE AND NOT IN # examples/13, and the difference is which hosts the manifest has to - # work on. `deps` is not conditional on a target -- measured: - # "[target.aarch64-ios-sim.xlings] does not accept 'deps'" -- and - # `xim:apple-simulator-tools` exists for macosx alone, so an + # work on. A tool declaration is not conditional on a target -- + # measured: "[target.aarch64-ios-sim.xlings] does not accept 'deps'" + # -- and `xim:apple-simulator-tools` exists for macosx alone, so an # unconditional declaration breaks a Linux build. This fixture is # macOS-only, so it can say it. - [xlings] - deps = ["xim:apple-simulator-tools"] + # + # `[xlings.workspace]` and not `[xlings] deps`, which mcpp reports as + # superseded: "deps is superseded by [xlings.workspace] and will stop + # being read. It is honoured for now." + [xlings.workspace] + "xim:apple-simulator-tools" = "" TOML cat >> /tmp/iostest/mcpp.toml << 'TOML' diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 1b65aa01..c0515652 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -10426,8 +10426,29 @@ prepare_build(bool print_fingerprint, auto xlEnv = mcpp::config::make_xlings_env(**cfg); for (auto const& spec : xlingsSpecs) { auto ref = mcpp::xlings::paths::parse_xpkg_ref(spec); - if (auto dir = mcpp::xlings::paths::xpkg_payload(xlEnv, ref)) + if (auto dir = mcpp::xlings::paths::xpkg_payload(xlEnv, ref)) { ctx.xlingsDepBinDirs.push_back(*dir / "bin"); + // AND THE PAYLOAD ROOT, BECAUSE A FLAT LAYOUT IS A + // LAYOUT THIS INDEX ALREADY SHIPS. + // + // `bin/` is the convention and stays first. It is not + // universal: `xim:7zip` puts `7zz` straight into its + // install directory, and so did the first version of + // `xim:apple-simulator-tools` -- which is how this was + // measured, on a macOS runner with the package + // correctly installed: + // + // error: runner 'simctl-run' for 'aarch64-ios-sim' + // was not found on any search path. + // Searched: .../xim-x-apple-simulator-tools/0.1.0/bin + // + // The directory searched was right and the program was + // one level up. Two directories per package is cheaper + // than a rule every recipe has to know, and a recipe + // that does use `bin/` is unaffected because that entry + // is still tried first. + ctx.xlingsDepBinDirs.push_back(*dir); + } } } } From 0f45d8129811bc1619f850c9faeefae3ce90b84d Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:48:07 +0800 Subject: [PATCH 08/14] docs(design): the criteria table carries what was measured, and the implementation's own review The plan's nine-angle self-review was written before any of this was built. This adds the review of the IMPLEMENTATION, whose unit is a measured defect rather than an angle: nineteen findings, nine of them in mcpp itself, each with the reading that made it visible and the class it belongs to. Four classes account for most of them. A decision written in several places with all but one repaired (the install path composed /bin itself). One question with two inputs of which one was read (the payload name was filled and never consulted). A branch selected by the HOST serving a target it would get wrong -- three findings, and the note that already said so had been read as a statement about openkal rather than about every target that branch would serve. And a criterion whose object was wrong: DEVELOPER_DIR=/nonexistent did not make the SDK unlocatable, so the host-surface claim measured nothing. The criteria table now carries the reading beside each claim, including the two the measurement changed: simctl-run is a boot-and-spawn wrapper and not a bundle builder, and openkal.task on Emscripten cannot be exercised end to end because -pthread is a whole-graph ABI switch mcpp has no channel for. Four gaps are recorded and deliberately not closed, each with a measurement behind it: no per-target tool axis, no whole-graph flag channel, xim:e2fsprogs's debugfs, and a device runner that needs a signature. --- ...k-toolchains-and-ios-local-verification.md | 197 ++++++++++++++++-- 1 file changed, 180 insertions(+), 17 deletions(-) diff --git a/.agents/docs/2026-09-11-sdk-toolchains-and-ios-local-verification.md b/.agents/docs/2026-09-11-sdk-toolchains-and-ios-local-verification.md index 52259197..08fad17c 100644 --- a/.agents/docs/2026-09-11-sdk-toolchains-and-ios-local-verification.md +++ b/.agents/docs/2026-09-11-sdk-toolchains-and-ios-local-verification.md @@ -484,24 +484,187 @@ two `cfg` lines in `E4b`'s example. Each claim below fails when its subject is removed, which is the only reason to write it down. -| claim | criterion | +| claim | criterion | measured | +|---|---|---| +| A: the device row runs | qemu-user run in CI on a Linux runner, asserting `1-2-3` and exit 0; the extraction is the packaged 7zip, not a host tool | yes -- and the packaged tool is `xim:7zip`, not `xim:e2fsprogs`, whose `debugfs` SIGFPEs on every filesystem-opening command | +| B: one spelling | `toolchain = "ndk@…"` is refused AT PARSE with "unknown toolchain", not by the capability gate | yes, with an A/B: re-adding the alias turns the assertion red | +| C: the engine stops knowing | delete `ndk_host_tag()`'s call site and the build still resolves, because the descriptor answered; and a malformed descriptor is refused naming the file | yes -- a descriptor naming `oddly/named/clang++` resolved there, a path no engine derivation produces; a non-string `platform_floor` was refused naming the file and the key | +| C: no flag day | a payload with no descriptor resolves exactly as today -- asserted against the released android-ndk | yes, against the installed r30 payload: with the file removed the resolution line and the effective triple are byte-identical | +| C: the floor and the defines travel | not in the original list, and each needs its own reading or it is carried by the frontend's | yes -- `platform_floor = "26"` gave `…-android26` while `meta/platforms.json` says 21; an added define appeared in the std module's command AND in its cache identity | +| D: iOS builds | macOS runner, `xim:llvm` plus the located SDK, artefact is Mach-O arm64 with the iOS platform in `LC_BUILD_VERSION` | yes -- `platform 2` (IOS) for the device and `platform 7` (IOSSIMULATOR) for both simulator rows, `minos 18.0`, `sdk 18.5` | +| D: the simulator runs | macOS runner, `mcpp run --target aarch64-ios-sim` prints `1-2-3` | yes, through the `runner` and `xim:apple-simulator-tools`; and separately under a bare `simctl spawn`, which is what proved a bundle is not needed | +| D: the host surface is bounded | on a macOS runner with `xcode-select` pointing nowhere, both iOS rows fail with a message naming the SDK -- and no other row changes | THE CRITERION WAS WRONG AND WAS REPLACED. `DEVELOPER_DIR=/nonexistent` did not make the SDK unlocatable -- `xcrun` ignores an invalid developer directory and falls back -- so the iOS build SUCCEEDED and the step asserted nothing. The claim now lives where the SDK is genuinely absent: e2e 641 on every non-Apple host, with the refusal required to name the SDK, the `xcrun` command, the Command-Line-Tools note and the compiler, and to arrive before any payload is resolved | +| E1: Android shares the implementation | merged and green against the RELEASED engine: both ABIs build, objects name no C library symbol, and a program over openkal alone ran on an emulator | yes (openkal-linux 0.12.0) | +| E2: iOS reuses it | `openkal-macos` compiles for the three iOS rows on a macOS runner, and its objects name no C library symbol -- the same check the Android leg applies, against a third libc | the `cfg` line is in `examples/portable`; the compile leg belongs to openkal-macos's own CI and is not in this batch | +| E3: the Web implementation conforms | the conformance suite passes for the groups it provides; and a program using `kal_process_spawn` fails at LINK naming the symbol, which is the criterion that the absent groups are absent rather than present-and-failing | yes -- 86 held, 0 did not hold, 13 not observed; and `wasm-ld: error: obj/main.o: undefined symbol: kal_process_spawn` | +| E4: the examples build | `examples/portable` builds for the host and for all five platforms it names -- it currently builds for NONE, which is why this is a criterion and not an assumption | host, both Android ABIs: yes. The Web leg resolves once `openkal-emscripten` is in the index, which is the ring this ecosystem cannot untangle: the example is always second | + +**The premise held.** A macos-15 runner ships Xcode 16.4 with both located SDKs +at 18.5 and five bootable iOS simulator runtimes (18.5, 18.6, 26.0, 26.1, +26.2). So D1, D2 and D4 are all CI claims and none of them is local-only. + +**Two things the measurement changed about this design.** + +`simctl-run` is a boot-and-spawn wrapper and not a bundle builder. This record +said a bare Mach-O "cannot be launched by `simctl`, so the program wraps it in +a minimal bundle" -- true of `simctl launch`, which needs an installed `.app`, +and false of `simctl spawn`, which takes an executable. Measured against an +artefact with no bundle, no signature and no `Info.plist`: `1-2-3`, exit 0. + +`openkal.task` on Emscripten is behind a feature and cannot yet be exercised +end to end. `-pthread` selects a different C library build, memory model and +loader contract, so it is a property of the WHOLE LINK -- and mcpp has no +channel for a flag that applies to a whole dependency graph, so the +specification package's own modules compile without it and the module cache +refuses the mix. The interface is therefore absent at link rather than present +and failing, which is what clause 6.1 asks for, and the gap is recorded in +openkal-emscripten's README as the engine change it is. + +## What the implementation found, and what each finding is about + +The plan's self-review is above and was written before any of this was built. +This section is the review of the implementation, and its unit is a measured +defect rather than an angle: each row is something that was wrong, the reading +that made it visible, and the class it belongs to. Nineteen, of which nine are +in mcpp itself. + +### A decision written in several places, all but one repaired + +| finding | reading | +|---|---| +| `mcpp toolchain install emsdk` and `… android-ndk` had never worked | `error: installed package has no known C++ frontend in '.../xim-x-emsdk/6.0.9/bin'` | + +`payload_frontend`'s own comment records that five callers composed +`/bin` themselves and so could not see where the package says its +compiler is. Four were repaired when that function was written; the install +path was not, so both SDK payloads fetched correctly and then looked for +`clang++` in a directory neither keeps it in. The install and the build now ask +one function, which is what makes them unable to disagree. + +### One question with two inputs, of which one was read + +| finding | reading | +|---|---| +| the same install failure, second cause | `emsdk@6.0.9` resolved the generic llvm shape | + +`to_xim_package` decided the payload from the TARGET. That is right for the +spelling a build uses and there is no target at all in an install, so a spec +that NAMED the payload was answered by a field nobody had filled. `payloadName` +now decides first and the target remains the answer for a spec that names no +payload -- which is the escape-hatch spelling the capability gate has to be +able to refuse, so both inputs are still read and neither is guessed. + +### A special case that was a table + +| finding | reading | +|---|---| +| `em++`'s C compiler became `em` | `/bin/sh: 1: .../emscripten/em: not found` | + +Dropping `++` is clang's rule and was being applied as the rule; `g` was +already a special case for exactly this reason. The property is "this driver +names its C compiler with a different word", and a table can be read as the +list of drivers for which that is true. Found by the conformance suite's one C +translation unit -- the only C in this ecosystem's openkal work. + +### A value that acquires the receiving layer's semantics + +| finding | reading | +|---|---| +| a descriptor naming `/usr/bin/g++` passed the guard on Windows | `path("/usr/bin/g++").is_absolute()` is FALSE there | + +Windows calls that path root-relative: it has a root directory and no root +name. `payloadRoot / "/usr/bin/g++"` then resolves to `C:/usr/bin/g++` -- a +host compiler chosen by a package, on the one host where the guard did not +look. The field is one shape on every host, so it is validated as a STRING and +positively. Asking a path type whether it is absolute is asking a question +whose meaning the host supplies. + +### A branch selected by the host, serving a target it gets wrong + +Three findings, one class, and the class is the sharpest thing in this batch. +`flags.cppm` and `hostflags.cppm` both branch on `needs_explicit_libcxx` -- +which asks where mcpp was built. That was the same question as "which platform +is this for" while macOS was the only Apple target mcpp could serve. + +| finding | what it would have produced | +|---|---| +| the macOS link branch carried no `--target` at all | a macOS binary from objects compiled as iOS | +| the Mach-O distribution cell chose the self-contained contract | a link of the payload's macOS `libc++.a` into an iOS artefact, which ld64 refuses | +| `hostflags` emitted `-mmacosx-version-min` whenever the host was macOS | `error: invalid argument '-mmacosx-version-min=14.0' not allowed with 'arm64-apple-ios18.0'` | + +The note two hundred lines below the first one already said "ONLY THE THIRD +BRANCH EVER CONSUMED `link_toolchain_flags`, WHICH IS WHERE `--target=` +LIVES" -- correct, and read as a statement about openkal rather than as a +statement about every target that branch would ever serve. + +### A per-machine artefact read before the command line + +| finding | reading | +|---|---| +| an iOS-simulator link with the right `-isysroot` used the macOS SDK | `ld64.lld: error: .../MacOSX.sdk/usr/lib/libc++.tbd(...) is incompatible with arm64 (iOS Simulator18.0.0)` | + +`post_install` writes the located macOS SDK into the payload's `clang++.cfg` so +that a native build is deterministic, and that file is read for search purposes +before the command line. An Apple cross therefore has to suppress it, which +mcpp's cross path already did and a hand-written probe did not. The probe was +wrong and the engine was right, and finding out which took one CI run. + +### A criterion whose object was wrong + +| finding | reading | +|---|---| +| the host-surface claim measured nothing | with `DEVELOPER_DIR=/nonexistent`, the iOS build SUCCEEDED | + +`xcrun` ignores an invalid developer directory and falls back to the recorded +one, so the environment change did not make the SDK unlocatable. The predicate +was right and the object was wrong -- the class this repository records most +often. The claim now lives where the SDK is genuinely absent, which is every +non-Apple host, and it asserts four things about the message plus that the +refusal arrives before any payload is resolved. + +### A fifth copy of a table that a checker covered four of + +| finding | reading | |---|---| -| A: the device row runs | qemu-user run in CI on a Linux runner, asserting `1-2-3` and exit 0; the extraction is the packaged 7zip, not a host tool | -| B: one spelling | `toolchain = "ndk@…"` is refused AT PARSE with "unknown toolchain", not by the capability gate | -| C: the engine stops knowing | delete `ndk_host_tag()`'s call site and the build still resolves, because the descriptor answered; and a malformed descriptor is refused naming the file | -| C: no flag day | a payload with no descriptor resolves exactly as today -- asserted against the released android-ndk | -| D: iOS builds | macOS runner, `xim:llvm` plus the located SDK, artefact is Mach-O arm64 with the iOS platform in `LC_BUILD_VERSION` | -| D: the simulator runs | macOS runner, `mcpp run --target aarch64-ios-sim` prints `1-2-3` | -| D: the host surface is bounded | on a macOS runner with `xcode-select` pointing nowhere, both iOS rows fail with a message naming the SDK -- and no other row changes | -| E1: Android shares the implementation | merged and green against the RELEASED engine: both ABIs build, objects name no C library symbol, and a program over openkal alone ran on an emulator | -| E2: iOS reuses it | `openkal-macos` compiles for the three iOS rows on a macOS runner, and its objects name no C library symbol -- the same check the Android leg applies, against a third libc | -| E3: the Web implementation conforms | the conformance suite passes for the groups it provides; and a program using `kal_process_spawn` fails at LINK naming the symbol, which is the criterion that the absent groups are absent rather than present-and-failing | -| E4: the examples build | `examples/portable` builds for the host and for all five platforms it names -- it currently builds for NONE, which is why this is a criterion and not an assumption | - -**One premise needs measuring before D is scheduled**: that GitHub's macOS -runners ship both an iOS SDK and a bootable simulator. If they ship the SDK but -no simulator, D1/D2 are still verifiable in CI and D4 is a local-only claim, -which changes the tier the sim rows can reach and nothing else in this design. +| `test_toolchain_triple.cpp` asserted `preview` after the row and four documents said `verified` | the suite went red; `check_target_tiers.py` said "OK: 29 target tiers agree across 4 documents" | + +A literal in a test is in neither the engine's table nor the documents, so a +checker over documents cannot see it. The test's tier claims are now one-line +pairs -- a shape the checker can read -- and it reads the test file as a fifth +document. Removing the fix makes the checker fail, which is the only reason to +add one. + +### And the platform refused two mechanisms outright + +`openkal-emscripten` is where the specification met a platform that says no +rather than differently. + +| finding | reading | +|---|---| +| `.init_array` with `(argc, argv, envp)` | `wasm-ld: error: constructor functions cannot take arguments` | +| `EM_ASM` using `stringToUTF8` into a `_malloc` buffer | `Aborted(malloc() called but not included in the build)` | +| `handle.h` copied from openkal-linux | `handle.h:22: warning: shift count >= width of type` | +| `kal_node_info::self_size` ignored | `DID NOT HOLD an enquiry writes no more of the structure than the caller stated` | + +The first is not a calling-convention difference to be careful about -- wasm's +start section takes no arguments, so the mechanism does not exist. The second +is the rule that a library cannot require an export list from every program +that uses it. The third is a constant that was a property of the machine the +file was written on, travelling as if it were a property of the scheme. The +fourth is a field the caller sets and the implementation must honour, and the +suite passes a deliberately short structure to find out. + +## Gaps this batch recorded and did not close + +Each is an engine or package change with a measurement behind it, and each was +left rather than worked around. + +| gap | measurement | why it was left | +|---|---|---| +| no per-target tool axis | `error: [target.aarch64-ios-sim.xlings] does not accept 'deps'` | a runner's program belongs beside the row that names it; declaring it at the top level makes `examples/13`'s Linux build depend on a macOS-only package, so the example asks the reader to install it | +| no whole-graph flag channel | `error: POSIX thread support was disabled in precompiled file '.../openkal.types.pcm' but is currently enabled` | `-pthread` is an ABI switch for every unit in the link including a dependency's; `openkal.task` is gated behind a feature so the absence is a link error rather than a present-and-failing operation | +| `xim:e2fsprogs`'s `debugfs` | SIGFPE on every filesystem-opening command, while dumpe2fs/e2fsck/tune2fs from the same build work | recorded in that recipe; nothing else in the index depends on it, and `android-system-image` now reads ext4 with `xim:7zip` | +| a device runner for `aarch64-ios` | none -- it needs a signature the developer owns | R12's subject, and a package cannot supply a signature | ## Deliberately not done From 59a10d48fb22aba05c22d0832395010d30afb46d Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Fri, 11 Sep 2026 21:10:22 +0800 Subject: [PATCH 09/14] feat(toolchain): the three iOS tiers move, each on its own reading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MEASURED ON macos-15 (Xcode 16.4, iPhoneOS and iPhoneSimulator 18.5, `ios_deployment_target = "18.0"`), through mcpp built from this branch: aarch64-ios-sim verified arm64 platform 7 (IOSSIMULATOR) minos 18.0 mcpp run --target aarch64-ios-sim -> 1-2-3 aarch64-ios preview arm64 platform 2 (IOS) minos 18.0 x86_64-ios-sim preview x86_64 platform 7 (IOSSIMULATOR) minos 18.0 The simulator row ran through the SUPPORTED PATH -- the `runner` a manifest declares and the program `xim:apple-simulator-tools` provides -- and not only under a hand-written `simctl spawn`. This repository has a standing note that publishing and verifying an asset is not the same as being able to use it through a supported path, and the difference here was a defect: the program was at the payload root and mcpp searched only `/bin`. NEITHER `preview` IS WORK NOT YET DONE. `aarch64-ios` cannot be run off a device without a signature the developer owns, which is not something a build tool or a package can supply; `x86_64-ios-sim` cannot be run on an Apple-silicon host, because a simulator runs the HOST's architecture. Both are tiers bounded by a fact rather than by a gap, and the rows say so. `platform 2` AGAINST `platform 7` IS THE PAIR WORTH HAVING A READING FOR. A successful build cannot tell them apart, and an artefact reporting IOSSIMULATOR from the device row is one no later step refuses. A NEW REFUSAL CODE, BECAUSE THE MATRIX COMPARES REASONS AND NOT ONLY OUTCOMES. `apple-sdk-absent` is distinct from `host-cannot-serve`, which says no payload here produces the target and waits for the graph in case a package supplies the system -- an Apple SDK is not redistributable, so no package can and nothing a later step learns changes the answer. It is distinct from `tier-planned`, which says the row is not wired: these rows are. Without a code the refusal would be recorded as `other`, which `check_matrix_reasons.sh` refuses on the ground that it freezes an unnamed branch into the expected table. THE TIER MOVE REACHES SIX PLACES AND A SEVENTH THAT WOULD HAVE STAYED GREEN. The engine's table, both READMEs (where one combined row became three, because the three tiers now differ), both copies of docs/21 -- including the per-host columns, which said `planned` everywhere and now say `SDK` on macos-arm64 and `—` elsewhere -- the test's one-line pairs, and `examples/13`'s matrix. The seventh is e2e 641, whose simulator case asserted `tier-planned`: it would have gone green on an expectation the table had left behind, which is the shape `check_target_tiers.py` was written for one axis over. --- README.md | 12 ++- README.zh-CN.md | 10 ++- docs/21-the-target-triple.md | 6 +- docs/zh/21-the-target-triple.md | 6 +- examples/13-platform-targets/README.md | 6 +- modules/toolchain-model/src/triple.cppm | 46 ++++++++++-- src/build/prepare.cppm | 5 ++ src/build/refusal.cppm | 8 ++ ...ws_are_wired_and_the_simulator_is_a_row.sh | 63 ++++++++++++---- tests/matrix/expected.tsv | 75 ++++++++++--------- tests/unit/test_toolchain_triple.cpp | 6 +- 11 files changed, 168 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index 9aa55310..b0b6935b 100644 --- a/README.md +++ b/README.md @@ -422,7 +422,9 @@ list` reports for this machine): | `wasm32-emscripten` | `emsdk@6.0.9` — Emscripten ships its own sysroot and its own libc++ module surface; `mcpp run` executes the module with `node` | verified | | `x86_64-linux-android` | `android-ndk@30.0.16248370` — bionic from the NDK, one payload for both ABIs; ran on an API 24 x86_64 emulator image | verified | | `aarch64-linux-android` | the same payload and the same build; ran under qemu-user over the system image's own bionic, which the platform emulator cannot do from an x86_64 host | verified | -| `aarch64-ios` · `aarch64-ios-sim` · `x86_64-ios-sim` | the iPhoneOS and iPhoneSimulator SDKs ship inside Xcode and are not redistributable, so the blocker is a licence rather than a payload | planned | +| `aarch64-ios-sim` | llvm 22 plus the machine's iPhoneSimulator SDK, which mcpp locates rather than installs; ran on a simulator through `simctl-run` ³ | verified | +| `aarch64-ios` | the same split for the device; the artefact names the iOS platform, and running it off a device needs a signature the developer owns ³ | preview | +| `x86_64-ios-sim` | the same build; nothing ran it, because a simulator runs the host's architecture and the machine measured was Apple silicon ³ | preview | `verified` an image has been built **and run** for the row, qemu and wine included · `preview` it builds and links, and no emulator run has been recorded @@ -449,6 +451,14 @@ such a target is refused rather than attempted. > payload produces these targets. The C library, startup code, memory layout > and emulator travel with a board-support package rather than with mcpp — see > [40 — Bare-Metal and Freestanding Targets](docs/40-baremetal.md). +> +> ³ The three iOS rows need a macOS host, and the compiler is still the +> ecosystem's: `xim:llvm` emits arm64 Mach-O for an iOS deployment target. What +> the machine supplies is the SDK, which ships inside Xcode and is not +> redistributable, so mcpp locates it through `xcrun` exactly as it has always +> located the macOS SDK — and refuses, naming the SDK, when it cannot. The +> simulator session belongs to `xim:apple-simulator-tools`. See +> [20 — Toolchain Management](docs/20-toolchains.md). ## Documentation diff --git a/README.zh-CN.md b/README.zh-CN.md index d3d3ae30..23eb186c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -409,7 +409,9 @@ mcpp 的身份模型是两条正交轴:**工具链** = `family@version`(family | `wasm32-emscripten` | `emsdk@6.0.9` —— Emscripten 自带 sysroot 和它自己的 libc++ 模块面;`mcpp run` 用 `node` 把模块跑起来 | verified | | `x86_64-linux-android` | `android-ndk@30.0.16248370` —— bionic 来自 NDK,一个载荷服务两个 ABI;在 API 24 的 x86_64 模拟器镜像上跑过 | verified | | `aarch64-linux-android` | 同一个载荷、同样的构建;在 qemu-user 上、配系统镜像自带的 bionic 跑过 —— 这是平台模拟器从 x86_64 宿主做不到的 | verified | -| `aarch64-ios` · `aarch64-ios-sim` · `x86_64-ios-sim` | iPhoneOS 与 iPhoneSimulator 的 SDK 在 Xcode 里且不可再分发,所以阻塞项是许可而不是载荷 | planned | +| `aarch64-ios-sim` | llvm 22 加上机器自己的 iPhoneSimulator SDK,mcpp 定位而不安装它;经 `simctl-run` 在模拟器上跑过 ³ | verified | +| `aarch64-ios` | 真机取同一种切分;产物命名 iOS 平台,而把它跑在一台设备上需要开发者自己拥有的签名 ³ | preview | +| `x86_64-ios-sim` | 同一次构建;没有东西跑过它,因为模拟器跑宿主的架构,而被测的那台是 Apple 芯片 ³ | preview | `verified` 该行的镜像已被构建**并运行**过,qemu 与 wine 都算 · `preview` 可构建 可链接,未记录过模拟器运行 · `planned` 已登记在词表中,尚未接线 —— 面向这类目标 @@ -431,6 +433,12 @@ mcpp 的身份模型是两条正交轴:**工具链** = `family@version`(family > LLVM 载荷的宿主都能产出这些目标。C 库、启动代码、内存布局与模拟器随板级支持包 > 走,而不随 mcpp 走 —— 见 > [40 — 裸机与 freestanding 目标](docs/zh/40-baremetal.md)。 +> +> ³ 三条 iOS 行需要一台 macOS 宿主,而编译器仍然是生态的:`xim:llvm` 能为一个 iOS +> 部署目标产出 arm64 Mach-O。机器供给的是 SDK —— 它在 Xcode 里且不可再分发,所以 +> mcpp 通过 `xcrun` 定位它,与它一直以来定位 macOS SDK 的方式完全相同 —— 并在找不到 +> 时点名那个 SDK 拒绝。模拟器那次会话属于 `xim:apple-simulator-tools`。见 +> [20 —— 工具链管理](docs/zh/20-toolchains.md)。 ## 文档 diff --git a/docs/21-the-target-triple.md b/docs/21-the-target-triple.md index 08e6aea0..0b3ed095 100644 --- a/docs/21-the-target-triple.md +++ b/docs/21-the-target-triple.md @@ -487,9 +487,9 @@ other's rows. | `armv7a-none-eabihf` | verified | `llvm@22.1.8` | payload | payload | payload | payload | | `aarch64-linux-android` | verified | `android-ndk@30.0.16248370` | payload | payload | payload | — | | `x86_64-linux-android` | verified | `android-ndk@30.0.16248370` | payload | payload | payload | — | -| `aarch64-ios` | planned | — | planned | planned | planned | planned | -| `aarch64-ios-sim` | planned | — | planned | planned | planned | planned | -| `x86_64-ios-sim` | planned | — | planned | planned | planned | planned | +| `aarch64-ios` | preview | `llvm@22.1.8` | — | — | SDK | — | +| `aarch64-ios-sim` | verified | `llvm@22.1.8` | — | — | SDK | — | +| `x86_64-ios-sim` | preview | `llvm@22.1.8` | — | — | SDK | — | | `wasm32-emscripten` | verified | `emsdk@6.0.9` | payload | payload | payload | payload | `payload` a toolchain payload here produces it · `graph` no payload, but a diff --git a/docs/zh/21-the-target-triple.md b/docs/zh/21-the-target-triple.md index ad69bca2..e283b4d8 100644 --- a/docs/zh/21-the-target-triple.md +++ b/docs/zh/21-the-target-triple.md @@ -437,9 +437,9 @@ CRT;图供给时是 `musl`。一个目标字符串,两个不同的 C 库 —— | `armv7a-none-eabihf` | verified | `llvm@22.1.8` | 载荷 | 载荷 | 载荷 | 载荷 | | `aarch64-linux-android` | verified | `android-ndk@30.0.16248370` | payload | payload | payload | — | | `x86_64-linux-android` | verified | `android-ndk@30.0.16248370` | payload | payload | payload | — | -| `aarch64-ios` | planned | — | planned | planned | planned | planned | -| `aarch64-ios-sim` | planned | — | planned | planned | planned | planned | -| `x86_64-ios-sim` | planned | — | planned | planned | planned | planned | +| `aarch64-ios` | preview | `llvm@22.1.8` | — | — | SDK | — | +| `aarch64-ios-sim` | verified | `llvm@22.1.8` | — | — | SDK | — | +| `x86_64-ios-sim` | preview | `llvm@22.1.8` | — | — | SDK | — | | `wasm32-emscripten` | verified | `emsdk@6.0.9` | payload | payload | payload | payload | `载荷` 这里有工具链载荷产出它 · `图` 没有载荷,但依赖可以供给系统 · diff --git a/examples/13-platform-targets/README.md b/examples/13-platform-targets/README.md index 6575bb14..e088a2a3 100644 --- a/examples/13-platform-targets/README.md +++ b/examples/13-platform-targets/README.md @@ -178,6 +178,6 @@ error: target aarch64-ios needs the iphoneos SDK, which this machine does not pr | `wasm32-emscripten` | verified | `emsdk@6.0.9` | 是,`node` | | `x86_64-linux-android` | verified | `android-ndk@30.0.16248370` | 是,平台模拟器 | | `aarch64-linux-android` | verified | `android-ndk@30.0.16248370` | 是,`qemu-aarch64-static` + 从镜像取出的 bionic | -| `aarch64-ios` | planned | `llvm@22.1.8` | 否 —— 真机需要开发者自己的签名 | -| `aarch64-ios-sim` | planned | `llvm@22.1.8` | 是,`simctl-run`(macos-15) | -| `x86_64-ios-sim` | planned | `llvm@22.1.8` | 否 —— 模拟器跑宿主架构,而那台宿主是 arm64 | +| `aarch64-ios` | preview | `llvm@22.1.8` | 否 —— 真机需要开发者自己的签名 | +| `aarch64-ios-sim` | verified | `llvm@22.1.8` | 是,`simctl-run`(macos-15) | +| `x86_64-ios-sim` | preview | `llvm@22.1.8` | 否 —— 模拟器跑宿主架构,而那台宿主是 arm64 | diff --git a/modules/toolchain-model/src/triple.cppm b/modules/toolchain-model/src/triple.cppm index c75aae40..bc35e7ca 100644 --- a/modules/toolchain-model/src/triple.cppm +++ b/modules/toolchain-model/src/triple.cppm @@ -746,7 +746,24 @@ inline constexpr TargetInfo kKnownTargets[] = { // The simulator is deliberately not a row. It has its own SDK and produces // its own object, so folding it in would make two targets share an // identity -- the mistake `x86_64-windows-musl` was added to undo. - { "aarch64-ios", "planned", "", "llvm@22.1.8","", false }, + // + // `preview`: BUILT, AND NOT RUN, AND NOTHING HERE CAN RUN IT. Measured + // 2026-09-11 on macos-15 (Xcode 16.4, iPhoneOS 18.5, + // `ios_deployment_target = "18.0"`): + // + // Mach-O 64-bit executable arm64 + // LC_BUILD_VERSION platform 2 (IOS) minos 18.0 sdk 18.5 + // + // `platform 2` against the simulator rows' `platform 7` is the reading + // worth having: a successful build cannot tell them apart, and an artefact + // reporting IOSSIMULATOR from this row is one no later step refuses. + // + // The row keeps `runner` unset and will stay `preview`. An artefact cannot + // be run off an iOS device without a signature the developer owns, which + // is not something a build tool or a package can supply -- so this is a + // tier bounded by a fact about the platform rather than by work not yet + // done. + { "aarch64-ios", "preview", "", "llvm@22.1.8","", false }, // THE SIMULATOR'S TWO ROWS. Not a convenience and not a runner: a // simulator build has its own SDK (`iPhoneSimulator.sdk`), produces its own // object, and takes `-mios-simulator-version-min` rather than @@ -759,13 +776,26 @@ inline constexpr TargetInfo kKnownTargets[] = { // an Intel one needs `x86_64`. A single row would describe a simulator half // the machines cannot run. // - // `planned`, and the blocker is the same licence question as the device - // row -- the simulator SDK ships inside Xcode and is no more - // redistributable than the iPhoneOS one. What these rows buy today is that - // `mcpp build --target aarch64-ios-sim` answers `tier-planned` naming the - // row, instead of `unknown target`, which was false. - { "aarch64-ios-sim", "planned", "", "llvm@22.1.8","", false }, - { "x86_64-ios-sim", "planned", "", "llvm@22.1.8","", false }, + // ONE `verified` AND ONE `preview`, AND THE DIFFERENCE IS THE HOST'S + // ARCHITECTURE RATHER THAN ANYTHING ABOUT THE ROWS. + // + // `aarch64-ios-sim` was built AND RUN. Measured 2026-09-11 on macos-15 + // (Xcode 16.4, iPhoneSimulator 18.5, `ios_deployment_target = "18.0"`): + // + // artefact Mach-O 64-bit executable arm64 + // LC_BUILD_VERSION platform 7 (IOSSIMULATOR) minos 18.0 + // run xcrun simctl spawn -> 1-2-3 + // + // with no bundle, no signature and no Info.plist -- the measurement that + // made `simctl-run` a boot-and-spawn wrapper rather than a bundle builder. + // + // `x86_64-ios-sim` builds and its artefact is correct -- Mach-O x86_64, + // the same `platform 7`, the same floor -- and nothing ran it, because a + // SIMULATOR RUNS THE HOST'S ARCHITECTURE and the runner is Apple silicon. + // That is a property of the machine the measurement was taken on, so the + // row stays `preview` until an Intel host takes it. + { "aarch64-ios-sim", "verified", "", "llvm@22.1.8","", false }, + { "x86_64-ios-sim", "preview", "", "llvm@22.1.8","", false }, // WEB IS THE OUTLIER, AND IT IS THE ONLY ONE OF THE THREE THAT CHANGES THE // MODEL RATHER THAN EXTENDING A TABLE. A new arch (`wasm32`), a new os diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index c0515652..f5c0c327 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -2493,6 +2493,11 @@ prepare_build(bool print_fingerprint, : mcpp::platform::macos::sdk_iphoneos; appleSdkLocated = mcpp::platform::macos::sdk_path(which); if (!appleSdkLocated) { + // A CODE, BECAUSE THE MATRIX COMPARES REASONS AND NOT ONLY + // OUTCOMES. A refusal with no code is recorded as `other`, + // which `check_matrix_reasons.sh` refuses on the ground that + // it freezes an unnamed branch into the expected table. + refusal::record(refusal::Code::AppleSdkAbsent); return std::unexpected(std::format( "target {} needs the {} SDK, which this machine does not " "provide.\n" diff --git a/src/build/refusal.cppm b/src/build/refusal.cppm index 2da47aba..2b50081c 100644 --- a/src/build/refusal.cppm +++ b/src/build/refusal.cppm @@ -93,6 +93,13 @@ enum class Code { // hold. Distinct from VersionFloorUnmet, which is about the machine: this // one is about two manifests disagreeing over a tool. ToolVersionConflict, + // An Apple SDK this target needs is not on this machine. Distinct from + // HostCannotServe, which says no PAYLOAD here produces the target and + // waits for the graph in case a package supplies the system: an Apple SDK + // is not redistributable, so no package can supply it and nothing a later + // step learns changes the answer. Distinct from TierPlanned, which says + // the row is not wired at all -- these rows are. + AppleSdkAbsent, Other, // a refusal that has not been given a code yet }; @@ -104,6 +111,7 @@ constexpr std::string_view name(Code c) { case Code::CompilerRequirementConflict: return "compiler-requirement-conflict"; case Code::TierPlanned: return "tier-planned"; + case Code::AppleSdkAbsent: return "apple-sdk-absent"; case Code::HostCannotServe: return "host-cannot-serve"; case Code::CapabilityPin: return "capability-pin"; case Code::ConventionUnreplaced: return "convention-unreplaced"; diff --git a/tests/e2e/641_the_android_rows_are_wired_and_the_simulator_is_a_row.sh b/tests/e2e/641_the_android_rows_are_wired_and_the_simulator_is_a_row.sh index f8f42d21..3edb490d 100755 --- a/tests/e2e/641_the_android_rows_are_wired_and_the_simulator_is_a_row.sh +++ b/tests/e2e/641_the_android_rows_are_wired_and_the_simulator_is_a_row.sh @@ -60,23 +60,48 @@ for target in aarch64-linux-android x86_64-linux-android; do fi done -# 3. THE SIMULATOR IS A ROW, so its spelling resolves. Before it existed, -# `--target aarch64-ios-sim` answered `unknown target`, which was false: the -# vocabulary has the device row and the simulator is a different target, not -# an unspellable one. +# 3. THE SIMULATOR IS A ROW, so its spelling resolves -- and the reason it is +# refused on this host moved when the tier did. +# +# Before the rows existed, `--target aarch64-ios-sim` answered `unknown +# target`, which was false. Then it answered `tier-planned`, which was true +# while nothing had built one. It now answers the SDK gate, because the rows +# are `verified` and `preview` and the thing this machine lacks is the SDK. +# +# ALL THREE ANSWERS ARE ASSERTED AGAINST, not just the first: `unknown +# target` is a lie about the vocabulary, and `tier-planned` after the tier +# moved would mean the table and the gate disagree. for target in aarch64-ios-sim x86_64-ios-sim; do d="$t/sim-$target"; pkg "$d" out=$( cd "$d" && MCPP_NO_AUTO_INSTALL=1 "$MCPP" build --target "$target" 2>&1 ) || true - if grep -q "unknown target" <<<"$out"; then - echo "FAIL: $target reported as unknown; it is a registered row" - fail=1 - elif grep -q "not yet supported (planned)" <<<"$out"; then - echo " ok: $target says planned, naming the row" - else - echo "FAIL: $target refused for neither reason" - grep -m2 -E "^error" <<<"$out" | sed 's/^/ /' - fail=1 - fi + case "$(uname -s)" in + Darwin) + # On a machine that has the SDK the row is simply built, and nothing + # here asserts the artefact -- ci-macos-ios.yml does that. + if grep -qE "unknown target|not yet supported \(planned\)" <<<"$out"; then + echo "FAIL: $target refused on a machine that can build it" + grep -m2 -E "^error" <<<"$out" | sed 's/^/ /' + fail=1 + else + echo " ok: $target is not refused on macOS" + fi + ;; + *) + if grep -q "unknown target" <<<"$out"; then + echo "FAIL: $target reported as unknown; it is a registered row" + fail=1 + elif grep -q "not yet supported (planned)" <<<"$out"; then + echo "FAIL: $target still says planned; the table says otherwise" + fail=1 + elif grep -q "needs the iphonesimulator SDK" <<<"$out"; then + echo " ok: $target is refused for the SDK, naming the simulator one" + else + echo "FAIL: $target refused for none of the three known reasons" + grep -m2 -E "^error" <<<"$out" | sed 's/^/ /' + fail=1 + fi + ;; + esac done # 4. AN EFFECTIVE TRIPLE mcpp PRINTS ITSELF MUST PARSE BACK. @@ -164,8 +189,14 @@ done # No payload is needed: the SDK is located before the toolchain is resolved, # which is itself a property worth asserting -- a machine without Xcode used # to download a 700 MB compiler before being told the compiler was not what -# was missing. Hence MCPP_NO_AUTO_INSTALL=1 and the explicit override, which -# opens the tier gate so that this gate is the one that answers. +# was missing. Hence MCPP_NO_AUTO_INSTALL=1. +# +# THE OVERRIDE IS KEPT AND NO LONGER LOAD-BEARING. It opened the tier gate +# while the rows were `planned`; they are not any more, so this case would +# reach the SDK gate without it. It stays because the claim it makes is +# stronger with it: declaring a toolchain says which compiler and says +# nothing about where the headers are, so the SDK gate must refuse even a +# project that has named its own. for target in aarch64-ios aarch64-ios-sim; do d="$t/sdk-$target" pkg "$d" "" "[target.$target]" 'toolchain = "llvm@22.1.8"' diff --git a/tests/matrix/expected.tsv b/tests/matrix/expected.tsv index 66af3ddb..970b6d0a 100644 --- a/tests/matrix/expected.tsv +++ b/tests/matrix/expected.tsv @@ -8,6 +8,7 @@ # status × reason —— 两者都参与比对: # ok / none 解析通过,且真的建出来了 # unsupported / tier-planned 词表里有这一行,还没有任何东西接线 +# unsupported / apple-sdk-absent 这台机器没有这个目标要的 Apple SDK # unsupported / capability-pin 这一行的工具链是能力陈述,不可被推翻 # unsupported / convention-unreplaced 约定被推翻了,而没有任何东西接替它 # unsupported / host-cannot-serve 本机没有载荷,图也没有供给这个系统 @@ -235,7 +236,7 @@ payload windows-x86_64 x86_64-windows-musl msvc@system - - - - - unsupported cap # 每一行接上线的时候,这里对应的那一格会从 tier-planned 变成别的东西,而这张表 # 会因此变红 —— 那正是它该做的事。缺的东西每一处都是**载荷**,不是引擎: # aarch64-linux-android / x86_64-linux-android xim:android-ndk -# aarch64-ios iPhoneOS SDK(先要一次许可判断) +# aarch64-ios iPhoneOS SDK(已定位,不打包) # wasm32-emscripten xim:emsdk,以及 #597 的目标模型 # graph linux-aarch64 aarch64-linux-android gcc@16.1.0 - - - - - unsupported capability-pin @@ -262,42 +263,42 @@ payload linux-x86_64 x86_64-linux-android llvm@22.1.8 - - - - - unsupported capa payload macos-arm64 x86_64-linux-android llvm@22.1.8 - - - - - unsupported capability-pin payload windows-x86_64 x86_64-linux-android llvm@22.1.8 - - - - - unsupported capability-pin payload windows-x86_64 x86_64-linux-android msvc@system - - - - - unsupported capability-pin -graph linux-aarch64 aarch64-ios gcc@16.1.0 - - - - - unsupported tier-planned -graph linux-aarch64 aarch64-ios-sim gcc@16.1.0 - - - - - unsupported tier-planned -graph linux-aarch64 x86_64-ios-sim gcc@16.1.0 - - - - - unsupported tier-planned -graph linux-x86_64 aarch64-ios gcc@16.1.0 - - - - - unsupported tier-planned -graph linux-x86_64 aarch64-ios-sim gcc@16.1.0 - - - - - unsupported tier-planned -graph linux-x86_64 x86_64-ios-sim gcc@16.1.0 - - - - - unsupported tier-planned -graph linux-x86_64 aarch64-ios llvm@22.1.8 - - - - - unsupported tier-planned -graph linux-x86_64 aarch64-ios-sim llvm@22.1.8 - - - - - unsupported tier-planned -graph linux-x86_64 x86_64-ios-sim llvm@22.1.8 - - - - - unsupported tier-planned -graph macos-arm64 aarch64-ios llvm@22.1.8 - - - - - unsupported tier-planned -graph macos-arm64 aarch64-ios-sim llvm@22.1.8 - - - - - unsupported tier-planned -graph macos-arm64 x86_64-ios-sim llvm@22.1.8 - - - - - unsupported tier-planned -graph windows-x86_64 aarch64-ios llvm@22.1.8 - - - - - unsupported tier-planned -graph windows-x86_64 aarch64-ios-sim llvm@22.1.8 - - - - - unsupported tier-planned -graph windows-x86_64 x86_64-ios-sim llvm@22.1.8 - - - - - unsupported tier-planned -graph windows-x86_64 aarch64-ios msvc@system - - - - - unsupported tier-planned -graph windows-x86_64 aarch64-ios-sim msvc@system - - - - - unsupported tier-planned -graph windows-x86_64 x86_64-ios-sim msvc@system - - - - - unsupported tier-planned -payload linux-aarch64 aarch64-ios gcc@16.1.0 - - - - - unsupported tier-planned -payload linux-aarch64 aarch64-ios-sim gcc@16.1.0 - - - - - unsupported tier-planned -payload linux-aarch64 x86_64-ios-sim gcc@16.1.0 - - - - - unsupported tier-planned -payload linux-x86_64 aarch64-ios gcc@16.1.0 - - - - - unsupported tier-planned -payload linux-x86_64 aarch64-ios-sim gcc@16.1.0 - - - - - unsupported tier-planned -payload linux-x86_64 x86_64-ios-sim gcc@16.1.0 - - - - - unsupported tier-planned -payload linux-x86_64 aarch64-ios llvm@22.1.8 - - - - - unsupported tier-planned -payload linux-x86_64 aarch64-ios-sim llvm@22.1.8 - - - - - unsupported tier-planned -payload linux-x86_64 x86_64-ios-sim llvm@22.1.8 - - - - - unsupported tier-planned -payload macos-arm64 aarch64-ios llvm@22.1.8 - - - - - unsupported tier-planned -payload macos-arm64 aarch64-ios-sim llvm@22.1.8 - - - - - unsupported tier-planned -payload macos-arm64 x86_64-ios-sim llvm@22.1.8 - - - - - unsupported tier-planned -payload windows-x86_64 aarch64-ios llvm@22.1.8 - - - - - unsupported tier-planned -payload windows-x86_64 aarch64-ios-sim llvm@22.1.8 - - - - - unsupported tier-planned -payload windows-x86_64 x86_64-ios-sim llvm@22.1.8 - - - - - unsupported tier-planned -payload windows-x86_64 aarch64-ios msvc@system - - - - - unsupported tier-planned -payload windows-x86_64 aarch64-ios-sim msvc@system - - - - - unsupported tier-planned -payload windows-x86_64 x86_64-ios-sim msvc@system - - - - - unsupported tier-planned +graph linux-aarch64 aarch64-ios gcc@16.1.0 - - - - - unsupported apple-sdk-absent +graph linux-aarch64 aarch64-ios-sim gcc@16.1.0 - - - - - unsupported apple-sdk-absent +graph linux-aarch64 x86_64-ios-sim gcc@16.1.0 - - - - - unsupported apple-sdk-absent +graph linux-x86_64 aarch64-ios gcc@16.1.0 - - - - - unsupported apple-sdk-absent +graph linux-x86_64 aarch64-ios-sim gcc@16.1.0 - - - - - unsupported apple-sdk-absent +graph linux-x86_64 x86_64-ios-sim gcc@16.1.0 - - - - - unsupported apple-sdk-absent +graph linux-x86_64 aarch64-ios llvm@22.1.8 - - - - - unsupported apple-sdk-absent +graph linux-x86_64 aarch64-ios-sim llvm@22.1.8 - - - - - unsupported apple-sdk-absent +graph linux-x86_64 x86_64-ios-sim llvm@22.1.8 - - - - - unsupported apple-sdk-absent +graph macos-arm64 aarch64-ios llvm@22.1.8 - - - - - ok none +graph macos-arm64 aarch64-ios-sim llvm@22.1.8 - - - - - ok none +graph macos-arm64 x86_64-ios-sim llvm@22.1.8 - - - - - ok none +graph windows-x86_64 aarch64-ios llvm@22.1.8 - - - - - unsupported apple-sdk-absent +graph windows-x86_64 aarch64-ios-sim llvm@22.1.8 - - - - - unsupported apple-sdk-absent +graph windows-x86_64 x86_64-ios-sim llvm@22.1.8 - - - - - unsupported apple-sdk-absent +graph windows-x86_64 aarch64-ios msvc@system - - - - - unsupported apple-sdk-absent +graph windows-x86_64 aarch64-ios-sim msvc@system - - - - - unsupported apple-sdk-absent +graph windows-x86_64 x86_64-ios-sim msvc@system - - - - - unsupported apple-sdk-absent +payload linux-aarch64 aarch64-ios gcc@16.1.0 - - - - - unsupported apple-sdk-absent +payload linux-aarch64 aarch64-ios-sim gcc@16.1.0 - - - - - unsupported apple-sdk-absent +payload linux-aarch64 x86_64-ios-sim gcc@16.1.0 - - - - - unsupported apple-sdk-absent +payload linux-x86_64 aarch64-ios gcc@16.1.0 - - - - - unsupported apple-sdk-absent +payload linux-x86_64 aarch64-ios-sim gcc@16.1.0 - - - - - unsupported apple-sdk-absent +payload linux-x86_64 x86_64-ios-sim gcc@16.1.0 - - - - - unsupported apple-sdk-absent +payload linux-x86_64 aarch64-ios llvm@22.1.8 - - - - - unsupported apple-sdk-absent +payload linux-x86_64 aarch64-ios-sim llvm@22.1.8 - - - - - unsupported apple-sdk-absent +payload linux-x86_64 x86_64-ios-sim llvm@22.1.8 - - - - - unsupported apple-sdk-absent +payload macos-arm64 aarch64-ios llvm@22.1.8 - - - - - ok none +payload macos-arm64 aarch64-ios-sim llvm@22.1.8 - - - - - ok none +payload macos-arm64 x86_64-ios-sim llvm@22.1.8 - - - - - ok none +payload windows-x86_64 aarch64-ios llvm@22.1.8 - - - - - unsupported apple-sdk-absent +payload windows-x86_64 aarch64-ios-sim llvm@22.1.8 - - - - - unsupported apple-sdk-absent +payload windows-x86_64 x86_64-ios-sim llvm@22.1.8 - - - - - unsupported apple-sdk-absent +payload windows-x86_64 aarch64-ios msvc@system - - - - - unsupported apple-sdk-absent +payload windows-x86_64 aarch64-ios-sim msvc@system - - - - - unsupported apple-sdk-absent +payload windows-x86_64 x86_64-ios-sim msvc@system - - - - - unsupported apple-sdk-absent graph linux-aarch64 wasm32-emscripten gcc@16.1.0 - - - - - unsupported capability-pin graph linux-x86_64 wasm32-emscripten gcc@16.1.0 - - - - - unsupported capability-pin graph linux-x86_64 wasm32-emscripten llvm@22.1.8 - - - - - unsupported capability-pin diff --git a/tests/unit/test_toolchain_triple.cpp b/tests/unit/test_toolchain_triple.cpp index a7dabb59..5f07e4a3 100644 --- a/tests/unit/test_toolchain_triple.cpp +++ b/tests/unit/test_toolchain_triple.cpp @@ -751,9 +751,9 @@ TEST(Triple, EachRowsTierMatchesTheEvidenceThatExistsForIt) { // when the project says nothing", and remains overridable -- asserted // below in ExactlyTheseRowsHaveACapabilityPin, whose expected set does // NOT contain these three. - for (auto [name, tier] : {std::pair{"aarch64-ios", "planned"}, - std::pair{"aarch64-ios-sim", "planned"}, - std::pair{"x86_64-ios-sim", "planned"}}) { + for (auto [name, tier] : {std::pair{"aarch64-ios", "preview"}, + std::pair{"aarch64-ios-sim", "verified"}, + std::pair{"x86_64-ios-sim", "preview"}}) { auto t = parse(name); ASSERT_TRUE(t.has_value()) << name; EXPECT_EQ(t->str(), name); From 4ff67730bf636ddac97d2e714f276291c4308602 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Fri, 11 Sep 2026 21:19:07 +0800 Subject: [PATCH 10/14] docs(design): the Web leg of examples/portable could not be a build, and the measurement said so The design record asked for that example to build for all five platforms it names. The fifth has twelve of the fifteen interfaces and the program uses two of the three absent ones, so the link names seven symbols: kal_process_{spawn,wait,close} and kal_task_{start,join,wait,wake}. Neither way of making it build is acceptable -- #ifdef-ing the one file in this ecosystem that exists to contain no platform awareness, or having openkal-emscripten provide operations it cannot perform, which is the shape clause 6.2 forbids. So the criterion was wrong and the example is better for it: it demonstrates the boundary, with the linker's own output as the reading. mcpplibs/openkal#30 carries the example's own correction. --- ...11-sdk-toolchains-and-ios-local-verification.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/.agents/docs/2026-09-11-sdk-toolchains-and-ios-local-verification.md b/.agents/docs/2026-09-11-sdk-toolchains-and-ios-local-verification.md index 08fad17c..0934ebab 100644 --- a/.agents/docs/2026-09-11-sdk-toolchains-and-ios-local-verification.md +++ b/.agents/docs/2026-09-11-sdk-toolchains-and-ios-local-verification.md @@ -497,13 +497,23 @@ write it down. | E1: Android shares the implementation | merged and green against the RELEASED engine: both ABIs build, objects name no C library symbol, and a program over openkal alone ran on an emulator | yes (openkal-linux 0.12.0) | | E2: iOS reuses it | `openkal-macos` compiles for the three iOS rows on a macOS runner, and its objects name no C library symbol -- the same check the Android leg applies, against a third libc | the `cfg` line is in `examples/portable`; the compile leg belongs to openkal-macos's own CI and is not in this batch | | E3: the Web implementation conforms | the conformance suite passes for the groups it provides; and a program using `kal_process_spawn` fails at LINK naming the symbol, which is the criterion that the absent groups are absent rather than present-and-failing | yes -- 86 held, 0 did not hold, 13 not observed; and `wasm-ld: error: obj/main.o: undefined symbol: kal_process_spawn` | -| E4: the examples build | `examples/portable` builds for the host and for all five platforms it names -- it currently builds for NONE, which is why this is a criterion and not an assumption | host, both Android ABIs: yes. The Web leg resolves once `openkal-emscripten` is in the index, which is the ring this ecosystem cannot untangle: the example is always second | +| E4: the examples build | `examples/portable` builds for the host and for all five platforms it names -- it currently builds for NONE, which is why this is a criterion and not an assumption | THE CRITERION WAS NOT SATISFIABLE AS WRITTEN, and the measurement is what said so. Host and both Android ABIs: yes. The Web leg RESOLVES and then fails at link naming seven symbols -- `kal_process_{spawn,wait,close}` and `kal_task_{start,join,wait,wake}` -- because this program uses `openkal.process` and `openkal.task` and this platform has neither. That is the mechanism working, not a gap: the line stays and the example now demonstrates the BOUNDARY, with the linker's own output as the evidence | **The premise held.** A macos-15 runner ships Xcode 16.4 with both located SDKs at 18.5 and five bootable iOS simulator runtimes (18.5, 18.6, 26.0, 26.1, 26.2). So D1, D2 and D4 are all CI claims and none of them is local-only. -**Two things the measurement changed about this design.** +**Three things the measurement changed about this design.** + +The Web leg of `examples/portable` cannot be a build. This record asked for +the example to build "for all five platforms it names", and the fifth is a +platform with twelve of the fifteen interfaces while the program uses two of +the three absent ones. Nothing about that is fixable without either +`#ifdef`-ing the one file in this ecosystem that exists to contain none, or +having `openkal-emscripten` provide operations it cannot perform -- the shape +clause 6.2 forbids. So the criterion was wrong and the example is better for +it: a program about portability that also shows where portability stops, with +seven undefined symbols as the reading. `simctl-run` is a boot-and-spawn wrapper and not a bundle builder. This record said a bare Mach-O "cannot be launched by `simctl`, so the program wraps it in From d4498573cdb8ab1ed706dcbec97a76b68bb4424f Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Fri, 11 Sep 2026 21:48:15 +0800 Subject: [PATCH 11/14] fix(toolchain): Apple is one answer for both of its platforms, and two sites asked it differently THE TIER MOVE EXPOSED TWO DEFECTS, ONE ON EACH SIDE OF THE SAME QUESTION. While the iOS rows were `planned` neither could be seen: `toolchain list` keeps a planned row unconditionally, so the listing was right by accident on every host. Measured as `scan (macos-arm64)` and `scan (linux-aarch64)` reaching three fewer cells than the expected table declares. `host_can_serve` ASKED `os == "macos"`. That was the same question as "is this an Apple target" while macOS was the only Apple target mcpp had. An iOS target fell through to the function's final `return false` -- on EVERY host, including the one that serves it. The consequence was not a refused build, because the SDK gate in prepare answers first; it was a listing that dropped the three iOS rows on macOS. It now asks `is_apple()`, a predicate the target table had carried with NO READER AT ALL until this line. `toolchain list`'s `graphCouldServe` CLAIMED THEM. Its own comment names `aarch64-macos` as correctly absent on a Linux host -- and that row was absent by ACCIDENT rather than by rule: its pin is empty, so `!info.pin.empty()` excluded it. The iOS rows have a pin now (`llvm@22.1.8`, the ordinary payload), so they entered the branch, found llvm in the index, and were listed on a host that cannot produce them. The discriminator that comment appeals to is real and is not the pin: it is whether a PACKAGE can supply the target's system, and no package supplies an Apple SDK. Asserted in both directions for all four Apple rows, because one direction is what that test already calls an exclusion which must not become a tautology: every Apple row is servable exactly where the SDK is. AND A SKIP I WROTE FROM AN ASSUMPTION WAS REFUTED BY A ROW IN THE SAME TABLE. The macOS scan also reported `graph x iOS` as `mismatch / build-failed`, and I first wrote it off as out of the fixture's domain -- "an Apple row's system comes from a located SDK and cannot be replaced by a graph-supplied musl". `graph macos-arm64 aarch64-macos` is measured `ok / none` with `musl(graph)` in its own column. The predicate I had written would have skipped that working cell. The real reason is one line of a dependency: openkal-musl-0.3.5/port/src/okm_syscall.c:444: error: incompatible pointer types passing 'uint64_t *' (aka 'unsigned long *') to parameter of type 'kal_u64 *' (aka 'unsigned long long *') One width, two type identities. musl's own `` spells `uint64_t` as `unsigned long` on LP64; `kal_u64` is `__UINT64_TYPE__`, which clang defines as `unsigned long long` for a DARWIN target. On every Linux and Windows musl target the two coincide, which is why that package's CI has never seen it: openkal-musl has never been built for an Apple target, and 0.13.1 has the same shape. The skip is therefore scoped to the rows the diagnostic covers, its comment says why macOS is NOT skipped, and the defect is recorded as a gap belonging to openkal-musl -- the iOS rows do not depend on it, since their system is the located SDK, which is the `payload` mode the tier rests on. `mismatch` is not written into the table for any of this. The table's own header forbids it, on the ground that writing one down declares a defect to be the expectation. Verified locally for this host after the change: 48 of 48 payload cells and 22 of 22 graph cells match the expected table. --- ...k-toolchains-and-ios-local-verification.md | 41 +++++++++++++++++++ src/toolchain/lifecycle.cppm | 17 +++++++- src/toolchain/registry.cppm | 19 ++++++++- tests/matrix/expected.tsv | 33 --------------- tests/matrix/scan.sh | 26 ++++++++++++ tests/unit/test_toolchain_registry.cpp | 24 +++++++++++ 6 files changed, 125 insertions(+), 35 deletions(-) diff --git a/.agents/docs/2026-09-11-sdk-toolchains-and-ios-local-verification.md b/.agents/docs/2026-09-11-sdk-toolchains-and-ios-local-verification.md index 0934ebab..f776431d 100644 --- a/.agents/docs/2026-09-11-sdk-toolchains-and-ios-local-verification.md +++ b/.agents/docs/2026-09-11-sdk-toolchains-and-ios-local-verification.md @@ -632,6 +632,46 @@ often. The claim now lives where the SDK is genuinely absent, which is every non-Apple host, and it asserts four things about the message plus that the refusal arrives before any payload is resolved. +### A predicate that was the same question until a second platform arrived + +| finding | reading | +|---|---| +| the three iOS rows were absent from `toolchain list` on macOS, and present on linux-x86_64 | a target-matrix scan that reached three fewer cells than the table declares | + +Two defects, one on each side of the same question, and the tier move is what +exposed them -- while the rows were `planned` the listing kept them +unconditionally, so neither could be seen. + +`host_can_serve` asked `os == "macos"`, which was the same question as "is this +an Apple target" while macOS was the only one. An iOS target fell through to +the function's final `return false`, on every host including the one that +serves it. It now asks `is_apple()` -- a predicate the table had carried with +NO READER AT ALL until this line. + +And `toolchain list`'s `graphCouldServe` claimed them. Its own comment names +`aarch64-macos` as correctly absent on a Linux host, and that row was absent by +ACCIDENT rather than by rule: its pin is empty, so `!info.pin.empty()` +excluded it. The iOS rows have a pin now, so they entered the branch, found +llvm in the index, and were listed on a host that cannot produce them. The +discriminator the comment appeals to is real and is not the pin -- it is +whether a PACKAGE can supply the target's system, and no package supplies an +Apple SDK. + +### A skip I wrote from an assumption, refuted by a row in the same table + +The macOS scan also reported `graph × iOS` as `mismatch / build-failed`, and I +first wrote it off as out of the fixture's domain -- "an Apple row's system +comes from a located SDK and cannot be replaced by a graph-supplied musl". A +row in the same table says otherwise: `graph macos-arm64 aarch64-macos` is +measured `ok / none`, with `musl(graph)` in its own c-abi column. The predicate +I had written would have skipped that working cell too. + +The real reason is one line of a dependency and is in the gap table above: the +fixture's pinned `openkal-musl 0.3.5` does not compile for a Darwin target at +all. So the skip is scoped to the rows the diagnostic covers, and its comment +says why macOS is NOT skipped -- because the alternative was a rule that the +table next to it disproves. + ### A fifth copy of a table that a checker covered four of | finding | reading | @@ -675,6 +715,7 @@ left rather than worked around. | no whole-graph flag channel | `error: POSIX thread support was disabled in precompiled file '.../openkal.types.pcm' but is currently enabled` | `-pthread` is an ABI switch for every unit in the link including a dependency's; `openkal.task` is gated behind a feature so the absence is a link error rather than a present-and-failing operation | | `xim:e2fsprogs`'s `debugfs` | SIGFPE on every filesystem-opening command, while dumpe2fs/e2fsck/tune2fs from the same build work | recorded in that recipe; nothing else in the index depends on it, and `android-system-image` now reads ext4 with `xim:7zip` | | a device runner for `aarch64-ios` | none -- it needs a signature the developer owns | R12's subject, and a package cannot supply a signature | +| `openkal-musl` has never been built for an Apple target | `okm_syscall.c:444: error: incompatible pointer types passing 'uint64_t *' (aka 'unsigned long *') to parameter of type 'kal_u64 *' (aka 'unsigned long long *')` | one width, two type identities: musl's own `` spells `uint64_t` as `unsigned long` on LP64, and `kal_u64` is `__UINT64_TYPE__`, which clang defines as `unsigned long long` for a DARWIN target. On every Linux and Windows musl target the two coincide, so that package's CI has never seen it. The current 0.13.1 has the same shape. It belongs to openkal-musl, and the iOS rows do not depend on it: their system is the located SDK, which is the `payload` mode the tier rests on | ## Deliberately not done diff --git a/src/toolchain/lifecycle.cppm b/src/toolchain/lifecycle.cppm index c8a21a02..29e32f0a 100644 --- a/src/toolchain/lifecycle.cppm +++ b/src/toolchain/lifecycle.cppm @@ -706,8 +706,23 @@ export int toolchain_list(const mcpp::config::GlobalConfig& cfg, // The discriminator is already in the table and needs no new field — // a row that names a compiler THIS host can install is one whose only // missing piece is the system, and a graph can supply a system. + // AND A LOCATED SDK IS NOT A SYSTEM A GRAPH CAN SUPPLY. + // + // The paragraph above names `aarch64-macos` as correctly absent on a + // Linux host, and it was absent by ACCIDENT rather than by rule: its + // pin is empty, so `!info.pin.empty()` excluded it. The iOS rows have + // a pin -- `llvm@22.1.8`, the ordinary payload -- so they entered this + // branch, found llvm in the index, and were listed on a host that + // cannot produce them. + // + // The discriminator the paragraph appeals to is real but is not the + // pin: it is whether a PACKAGE can supply the target's system. An + // Apple SDK is not redistributable, so none can, which makes every + // Apple row like `aarch64-macos` and unlike `x86_64-windows-musl`. + // Stating it removes the reliance on an empty field. bool graphCouldServe = false; - if (!planned && !installable_here(*t) && !info.pin.empty()) { + if (!planned && !installable_here(*t) && !info.pin.empty() + && !t->is_apple()) { auto at = info.pin.find('@'); auto fam = info.pin.substr(0, at == std::string_view::npos ? info.pin.size() : at); diff --git a/src/toolchain/registry.cppm b/src/toolchain/registry.cppm index 328674d2..e66aebd2 100644 --- a/src/toolchain/registry.cppm +++ b/src/toolchain/registry.cppm @@ -1202,7 +1202,24 @@ bool host_can_serve(const triple::Triple& target) { // `ok` on both hosts. if (target.is_pe() && target.is_musl()) return false; if (target.os == "windows") return bool(mcpp::platform::is_windows); - if (target.os == "macos") return bool(mcpp::platform::is_macos); + // APPLE, AND ONE ARM FOR BOTH OF ITS PLATFORMS. + // + // This read `os == "macos"`, which was the same question while macOS was + // the only Apple target mcpp had. An iOS target then fell through to the + // `return false` at the end of this function -- on EVERY host, including + // the one that serves it. + // + // The consequence was not a refused build: the SDK gate in prepare answers + // before this one. It was `toolchain list`, which drops a row this says no + // host can serve -- so the three iOS rows were absent from the list on + // macOS, and present on linux-x86_64 for the wrong reason (see + // `graphCouldServe` in mcpp.toolchain.lifecycle). Measured as a + // target-matrix scan that reached three fewer cells on linux-aarch64 than + // the expected table declares. + // + // `is_apple()` is the predicate the table already carries, and until now + // it had no reader at all. + if (target.is_apple()) return bool(mcpp::platform::is_macos); // Bare metal: every host can serve it, and that is a property of the // toolchain rather than a claim about payload coverage. clang and lld are diff --git a/tests/matrix/expected.tsv b/tests/matrix/expected.tsv index 970b6d0a..11f8fd21 100644 --- a/tests/matrix/expected.tsv +++ b/tests/matrix/expected.tsv @@ -263,42 +263,9 @@ payload linux-x86_64 x86_64-linux-android llvm@22.1.8 - - - - - unsupported capa payload macos-arm64 x86_64-linux-android llvm@22.1.8 - - - - - unsupported capability-pin payload windows-x86_64 x86_64-linux-android llvm@22.1.8 - - - - - unsupported capability-pin payload windows-x86_64 x86_64-linux-android msvc@system - - - - - unsupported capability-pin -graph linux-aarch64 aarch64-ios gcc@16.1.0 - - - - - unsupported apple-sdk-absent -graph linux-aarch64 aarch64-ios-sim gcc@16.1.0 - - - - - unsupported apple-sdk-absent -graph linux-aarch64 x86_64-ios-sim gcc@16.1.0 - - - - - unsupported apple-sdk-absent -graph linux-x86_64 aarch64-ios gcc@16.1.0 - - - - - unsupported apple-sdk-absent -graph linux-x86_64 aarch64-ios-sim gcc@16.1.0 - - - - - unsupported apple-sdk-absent -graph linux-x86_64 x86_64-ios-sim gcc@16.1.0 - - - - - unsupported apple-sdk-absent -graph linux-x86_64 aarch64-ios llvm@22.1.8 - - - - - unsupported apple-sdk-absent -graph linux-x86_64 aarch64-ios-sim llvm@22.1.8 - - - - - unsupported apple-sdk-absent -graph linux-x86_64 x86_64-ios-sim llvm@22.1.8 - - - - - unsupported apple-sdk-absent -graph macos-arm64 aarch64-ios llvm@22.1.8 - - - - - ok none -graph macos-arm64 aarch64-ios-sim llvm@22.1.8 - - - - - ok none -graph macos-arm64 x86_64-ios-sim llvm@22.1.8 - - - - - ok none -graph windows-x86_64 aarch64-ios llvm@22.1.8 - - - - - unsupported apple-sdk-absent -graph windows-x86_64 aarch64-ios-sim llvm@22.1.8 - - - - - unsupported apple-sdk-absent -graph windows-x86_64 x86_64-ios-sim llvm@22.1.8 - - - - - unsupported apple-sdk-absent -graph windows-x86_64 aarch64-ios msvc@system - - - - - unsupported apple-sdk-absent -graph windows-x86_64 aarch64-ios-sim msvc@system - - - - - unsupported apple-sdk-absent -graph windows-x86_64 x86_64-ios-sim msvc@system - - - - - unsupported apple-sdk-absent -payload linux-aarch64 aarch64-ios gcc@16.1.0 - - - - - unsupported apple-sdk-absent -payload linux-aarch64 aarch64-ios-sim gcc@16.1.0 - - - - - unsupported apple-sdk-absent -payload linux-aarch64 x86_64-ios-sim gcc@16.1.0 - - - - - unsupported apple-sdk-absent -payload linux-x86_64 aarch64-ios gcc@16.1.0 - - - - - unsupported apple-sdk-absent -payload linux-x86_64 aarch64-ios-sim gcc@16.1.0 - - - - - unsupported apple-sdk-absent -payload linux-x86_64 x86_64-ios-sim gcc@16.1.0 - - - - - unsupported apple-sdk-absent -payload linux-x86_64 aarch64-ios llvm@22.1.8 - - - - - unsupported apple-sdk-absent -payload linux-x86_64 aarch64-ios-sim llvm@22.1.8 - - - - - unsupported apple-sdk-absent -payload linux-x86_64 x86_64-ios-sim llvm@22.1.8 - - - - - unsupported apple-sdk-absent payload macos-arm64 aarch64-ios llvm@22.1.8 - - - - - ok none payload macos-arm64 aarch64-ios-sim llvm@22.1.8 - - - - - ok none payload macos-arm64 x86_64-ios-sim llvm@22.1.8 - - - - - ok none -payload windows-x86_64 aarch64-ios llvm@22.1.8 - - - - - unsupported apple-sdk-absent -payload windows-x86_64 aarch64-ios-sim llvm@22.1.8 - - - - - unsupported apple-sdk-absent -payload windows-x86_64 x86_64-ios-sim llvm@22.1.8 - - - - - unsupported apple-sdk-absent -payload windows-x86_64 aarch64-ios msvc@system - - - - - unsupported apple-sdk-absent -payload windows-x86_64 aarch64-ios-sim msvc@system - - - - - unsupported apple-sdk-absent -payload windows-x86_64 x86_64-ios-sim msvc@system - - - - - unsupported apple-sdk-absent graph linux-aarch64 wasm32-emscripten gcc@16.1.0 - - - - - unsupported capability-pin graph linux-x86_64 wasm32-emscripten gcc@16.1.0 - - - - - unsupported capability-pin graph linux-x86_64 wasm32-emscripten llvm@22.1.8 - - - - - unsupported capability-pin diff --git a/tests/matrix/scan.sh b/tests/matrix/scan.sh index 0e58aad4..fb67c8e7 100755 --- a/tests/matrix/scan.sh +++ b/tests/matrix/scan.sh @@ -166,6 +166,32 @@ for tc in $(compilers); do continue fi + # iOS 行同理,而**理由是被实测出来的一条缺陷,不是一句论域声明**。 + # + # 实测 2026-09-11(macos-arm64,这套依赖钉的 openkal-musl 0.3.5): + # + # openkal-musl-0.3.5/port/src/okm_syscall.c:444:60: error: incompatible + # pointer types passing 'uint64_t *' (aka 'unsigned long *') to + # parameter of type 'kal_u64 *' (aka 'unsigned long long *') + # + # 同一个宽度,两个类型身份:musl 自己的 `` 在 LP64 上把 `uint64_t` + # 拼作 `unsigned long`,而 `kal_u64` 是 `__UINT64_TYPE__` —— clang 为 + # **Darwin** 目标把它定义成 `unsigned long long`。在 Linux/Windows 的 musl + # 目标上两者同串,所以 openkal-musl 的 CI 从来没有见过这条;它**从未为任何 + # Apple 目标构建过**。当前的 0.13.1 仍是同一种写法。 + # + # ⚠️ macOS 行**没有**被略过,而这正是这条注释必须说清的地方: + # `graph × aarch64-macos` 实测是 `ok / none`。所以这不是「Apple 行不能由图 + # 供给」——那句话会被 macOS 当场否掉——而是「这套依赖钉的那个版本服务不了 + # iOS 行」。判据是那条诊断,不是这一族目标。 + # + # 写 `mismatch` 不是选项:这张表明文不收它,因为写下它就是把一个缺陷声明成 + # 期望。缺陷记在 .agents/docs 的 gap 表里,属于 openkal-musl。 + if [ "$MODE" = graph ] && printf '%s' "$t" | grep -qE -- '-ios(-sim)?$'; then + echo "scan: 略过 graph × $t —— openkal-musl 0.3.5 编不过 Apple cross(uint64_t/kal_u64 类型身份不同),见 scan.sh 注释" >&2 + continue + fi + { printf '[package]\nname = "mxscan"\nversion = "0.1.0"\n' [ "$MODE" = graph ] && printf '\n[dependencies]\nopenkal-musl = "0.3.5"\nopenkal-llvm-runtime = "0.1.3"\n' } > mcpp.toml diff --git a/tests/unit/test_toolchain_registry.cpp b/tests/unit/test_toolchain_registry.cpp index 713db3c7..ef3de815 100644 --- a/tests/unit/test_toolchain_registry.cpp +++ b/tests/unit/test_toolchain_registry.cpp @@ -465,6 +465,30 @@ TEST(SdkPayloads, ServedOnEveryHostTheSdkIsPublishedFor) { ASSERT_TRUE(mac.has_value()); EXPECT_FALSE(mcpp::toolchain::host_can_serve(*mac)); } + + // AND APPLE IS ONE ANSWER FOR BOTH OF ITS PLATFORMS, which this function + // did not say until there were two. + // + // It asked `os == "macos"`, the same question as "is this Apple" while + // macOS was the only Apple target mcpp had. An iOS target then fell + // through to the final `return false` -- on EVERY host, including the one + // that serves it. The consequence was not a refused build (the SDK gate in + // prepare answers first) but a `toolchain list` that dropped the three iOS + // rows on macOS while `graphCouldServe` listed them on linux-x86_64, and a + // target-matrix scan that reached three fewer cells than the table + // declares. + // + // ASSERTED IN BOTH DIRECTIONS, because one of them is what the earlier + // paragraph in this test calls an exclusion that must not become a + // tautology: every Apple row is servable exactly where the SDK is. + for (auto name : {"aarch64-macos", "aarch64-ios", "aarch64-ios-sim", + "x86_64-ios-sim"}) { + auto t = mcpp::toolchain::triple::parse(name); + ASSERT_TRUE(t.has_value()) << name; + EXPECT_TRUE(t->is_apple()) << name; + EXPECT_EQ(mcpp::toolchain::host_can_serve(*t), + bool(mcpp::platform::is_macos)) << name; + } } // ─── The payload is SAID, not only resolved (R3) ─────────────────────────── From 26c5ac916c6a30837b8a051fe443d7df52b80d40 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:07:18 +0800 Subject: [PATCH 12/14] test(e2e): case 7 answers for its host, which is the fix I wrote down and did not apply `host_can_serve` now answers for the host, and `toolchain list` therefore drops an Apple row where its SDK cannot be. e2e 641 case 7 still required the three iOS rows to appear with their pin -- true while they were `planned`, because a planned row stays listed everywhere so it is discoverable, and false the moment the tiers moved. FAIL: aarch64-ios does not name llvm in toolchain list I HAD ALREADY WRITTEN THIS FIX DOWN. While diagnosing the matrix failure I noted that case 7's claim must become host-conditional. Then I fixed cases 3 and 8, added the unit assertion, ran the unit suite, and pushed -- having changed the listing and not re-run the end-to-end test that asserts the listing. A unit suite passing is not evidence about a behaviour no unit test observes. Both arms are real. On macOS the rows are listed with their pin; elsewhere they are absent, and two companions keep that absence from reading as a gap: `aarch64-macos` is absent for the same reason, and `x86_64-macos` is present because a `planned` row stays discoverable on every host. The pin itself is a property of the table and is asserted where every host can ask it, in tests/unit/test_toolchain_triple.cpp. Third instance of one class in an hour -- the other two were the sandbox verification's section 2, caught by a dry run, and thirty rows of expected.tsv. The design record now carries the three together. --- ...k-toolchains-and-ios-local-verification.md | 25 ++++++++ ...ws_are_wired_and_the_simulator_is_a_row.sh | 57 ++++++++++++++----- 2 files changed, 69 insertions(+), 13 deletions(-) diff --git a/.agents/docs/2026-09-11-sdk-toolchains-and-ios-local-verification.md b/.agents/docs/2026-09-11-sdk-toolchains-and-ios-local-verification.md index f776431d..1e17c21a 100644 --- a/.agents/docs/2026-09-11-sdk-toolchains-and-ios-local-verification.md +++ b/.agents/docs/2026-09-11-sdk-toolchains-and-ios-local-verification.md @@ -657,6 +657,31 @@ discriminator the comment appeals to is real and is not the pin -- it is whether a PACKAGE can supply the target's system, and no package supplies an Apple SDK. +### The same criterion wrong in three places within one hour + +`host_can_serve` now answers for the host, which is correct and which changed +what `toolchain list` reports. Three criteria asserted the old answer, and each +was written by me: + +| where | the claim it made | how it surfaced | +|---|---|---| +| e2e 641 case 7 | the three iOS rows appear in the listing with their pin | `FAIL: aarch64-ios does not name llvm in toolchain list`, on CI | +| the sandbox verification's section 2 | the same | a local dry run of the script, before the run that mattered | +| `tests/matrix/expected.tsv` | 30 rows for hosts that cannot serve them | `scan (linux-aarch64)` reaching three fewer cells than declared | + +The first is the one worth recording, because I had already written the fix +down and not applied it: while diagnosing the matrix failure I noted that "case +7's claim must become host-conditional -- on macOS the rows are listed; on +other hosts they are correctly absent". Then I fixed cases 3 and 8, added the +unit test, ran the unit suite, and pushed. **I changed the listing and did not +re-run the end-to-end test that asserts the listing.** The unit suite passing +is not evidence about a behaviour no unit test observes. + +All three now name the host and carry both arms, and the Linux arm carries two +companions so that an absence cannot be read as a gap: `aarch64-macos` is +absent for the same reason, and `x86_64-macos` is present because a `planned` +row stays discoverable everywhere. + ### A skip I wrote from an assumption, refuted by a row in the same table The macOS scan also reported `graph × iOS` as `mismatch / build-failed`, and I diff --git a/tests/e2e/641_the_android_rows_are_wired_and_the_simulator_is_a_row.sh b/tests/e2e/641_the_android_rows_are_wired_and_the_simulator_is_a_row.sh index 3edb490d..2043fa68 100755 --- a/tests/e2e/641_the_android_rows_are_wired_and_the_simulator_is_a_row.sh +++ b/tests/e2e/641_the_android_rows_are_wired_and_the_simulator_is_a_row.sh @@ -156,23 +156,54 @@ else echo " ok: min_api_level = 24 is accepted by the manifest" fi -# 7. THE iOS ROWS NAME THEIR PAYLOAD, AND IT IS A CONVENTION PIN. The compiler -# is ours and only the SDK is Apple's: `xim:llvm` emits arm64 Mach-O for an -# iOS deployment target, so the rows pin it exactly as the wasm row pins -# emsdk. A capability pin would be wrong here -- `aarch64-macos` has the -# same constraint (Darwin needs clang) and is not a capability row -- so the -# pin is overridable, which case 8 relies on. +# 7. THE LISTING ANSWERS FOR THIS HOST, AND AN APPLE ROW IS SERVED ONLY WHERE +# ITS SDK IS. +# +# THIS CASE ASSERTED THE OPPOSITE FIRST AND WAS RIGHT FOR ONE RELEASE. It +# required the three iOS rows to appear with their pin, which held while +# they were `planned` -- `toolchain list` keeps a planned row on every host +# so it is discoverable. Once the tiers moved, the listing correctly +# dropped them on a non-Apple host and this case failed. The pin itself is +# a property of the TABLE and is asserted where every host can ask it, in +# tests/unit/test_toolchain_triple.cpp. +# +# BOTH ARMS ARE REAL, and the Linux arm carries two companions that keep an +# absence from being indistinguishable from a gap: `aarch64-macos` is +# absent for the same reason, so the rule is "this host cannot serve it" +# rather than "the rows are missing"; and `x86_64-macos` IS listed, being +# `planned` and therefore discoverable, so the absence is about serving and +# not about Apple. list=$( "$MCPP" toolchain list --format json 2>/dev/null ) -for target in aarch64-ios aarch64-ios-sim x86_64-ios-sim; do - if tr ',' '\n' <<<"$list" | grep -A6 "\"target\": *\"$target\"" \ - | grep -q "llvm"; then - echo " ok: $target names the llvm payload" +listed() { tr ',' '\n' <<<"$list" | grep -q "\"target\": *\"$1\""; } +case "$(uname -s)" in + Darwin) + for target in aarch64-ios aarch64-ios-sim x86_64-ios-sim; do + if listed "$target" && tr ',' '\n' <<<"$list" \ + | grep -A6 "\"target\": *\"$target\"" | grep -q "llvm"; then + echo " ok: $target is listed here and names the llvm payload" + else + echo "FAIL: $target is not listed with its pin on a host that serves it" + fail=1 + fi + done + ;; + *) + for target in aarch64-ios aarch64-ios-sim x86_64-ios-sim aarch64-macos; do + if listed "$target"; then + echo "FAIL: $target is listed on a host that cannot serve it" + fail=1 + else + echo " ok: $target is absent here, as an Apple row must be" + fi + done + if listed x86_64-macos; then + echo " ok: x86_64-macos is listed, being planned and discoverable" else - echo "FAIL: $target does not name llvm in toolchain list" - tr ',' '\n' <<<"$list" | grep -A6 "\"target\": *\"$target\"" | sed 's/^/ /' + echo "FAIL: x86_64-macos is absent; the absences above are then unexplained" fail=1 fi -done + ;; +esac # 8. AND THE SDK IS LOCATED, SO ITS ABSENCE IS A REFUSAL THAT NAMES IT. # From e84c14d76f12df988d22d237dff15e96bb38e0c0 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:48:55 +0800 Subject: [PATCH 13/14] fix(review): the iOS gate could not fail, and two smaller findings of the same pass THE macOS JOB THAT MEASURED THE iOS ROWS WAS A PROBE, AND IT STAYED ONE AFTER ITS MEASUREMENTS MOVED `aarch64-ios-sim` TO `verified`. Every step continued on error, so a regression in the Apple cross path would have printed `RUN-THROUGH-RUNNER-FAILED` inside a job reported green. Every reading of that job had been green, which is why only reading the workflow could find it. The job is split along that distinction: ios-host-surface the premise measurements; continues on error; runs on workflow_dispatch only, because a check that cannot fail, shown beside a gate, reads as a second gate ios-engine fails when an artefact's architecture, LC_BUILD_VERSION platform (2 device, 7 simulator) or minos differs from the row's -- compared as whole values, so an empty reading fails -- and when `mcpp run --target aarch64-ios-sim` does not exit 0 with `1-2-3` as a line of its output The fixture no longer declares `toolchain = "llvm@22.1.8"` for the three rows. That override was needed while they were `planned`, and it meant the job measured an override and never the rows' own pin. THE LOCATED SDK PATH REACHES THREE COMMAND LINES AND WAS QUOTED ON TWO. The std module's own command spliced it into a shell string unquoted, while every other path in that string goes through `shq`; an Xcode installed as `Xcode 16.app` would have broken only the module precompile. THE PAYLOAD ROOT JOINED `bin/` IN THE RUNNER SEARCH WITH A MEASUREMENT AND NO TEST. The rule is now `runner_lookup::payload_search_dirs`, and `AFlatPayloadIsFoundAtItsRoot` fails when it is reverted to `bin/` alone -- checked by reverting it. Two comments that still said "bin/ first, then PATH" now state the pair. Also: an emoji removed from a comment in tests/matrix/scan.sh; the design record's Status line said "for review before implementation" and now names where each item was implemented; its front matter is `landed` and the index is regenerated. Local: unit 111 passed, 0 failed; e2e 641 and 334 pass against the rebuilt binary. --- ...k-toolchains-and-ios-local-verification.md | 50 ++- .agents/docs/README.md | 4 +- .github/workflows/ci-macos-ios.yml | 350 ++++++------------ src/build/execute.cppm | 2 +- src/build/prepare.cppm | 41 +- src/build/runner_lookup.cppm | 22 ++ tests/matrix/scan.sh | 2 +- tests/unit/test_runner_lookup.cpp | 25 ++ 8 files changed, 233 insertions(+), 263 deletions(-) diff --git a/.agents/docs/2026-09-11-sdk-toolchains-and-ios-local-verification.md b/.agents/docs/2026-09-11-sdk-toolchains-and-ios-local-verification.md index 1e17c21a..f524df1f 100644 --- a/.agents/docs/2026-09-11-sdk-toolchains-and-ios-local-verification.md +++ b/.agents/docs/2026-09-11-sdk-toolchains-and-ios-local-verification.md @@ -1,12 +1,15 @@ --- subject: targets -status: active +status: landed --- # SDK toolchains, the payload/engine seam, and openkal across iOS, Android and Web -**Status:** design. Items A and E1 are measured and merged; the rest is for -review before implementation. +**Status:** implemented, in mcpp #612 (2026.9.11.4), xim-pkgindex #813 and +#820, openkal #29 and #30, mcpp-index #393, and the new repository +`mcpplibs/openkal-emscripten`. The design sections are kept as they were +reviewed; where the implementation departed from them, the sections from "What +the implementation found" onward record why. **Scope.** Five items, in dependency order. The first is measured and only needs writing down; the second removes something rather than adding; the third is the @@ -491,8 +494,8 @@ write it down. | C: the engine stops knowing | delete `ndk_host_tag()`'s call site and the build still resolves, because the descriptor answered; and a malformed descriptor is refused naming the file | yes -- a descriptor naming `oddly/named/clang++` resolved there, a path no engine derivation produces; a non-string `platform_floor` was refused naming the file and the key | | C: no flag day | a payload with no descriptor resolves exactly as today -- asserted against the released android-ndk | yes, against the installed r30 payload: with the file removed the resolution line and the effective triple are byte-identical | | C: the floor and the defines travel | not in the original list, and each needs its own reading or it is carried by the frontend's | yes -- `platform_floor = "26"` gave `…-android26` while `meta/platforms.json` says 21; an added define appeared in the std module's command AND in its cache identity | -| D: iOS builds | macOS runner, `xim:llvm` plus the located SDK, artefact is Mach-O arm64 with the iOS platform in `LC_BUILD_VERSION` | yes -- `platform 2` (IOS) for the device and `platform 7` (IOSSIMULATOR) for both simulator rows, `minos 18.0`, `sdk 18.5` | -| D: the simulator runs | macOS runner, `mcpp run --target aarch64-ios-sim` prints `1-2-3` | yes, through the `runner` and `xim:apple-simulator-tools`; and separately under a bare `simctl spawn`, which is what proved a bundle is not needed | +| D: iOS builds | macOS runner, `xim:llvm` plus the located SDK, artefact is Mach-O arm64 with the iOS platform in `LC_BUILD_VERSION` | yes -- `platform 2` (IOS) for the device and `platform 7` (IOSSIMULATOR) for both simulator rows, `minos 18.0`, `sdk 18.5`. Asserted by `ios-engine`, which fails when any of the three readings differs, an empty reading included | +| D: the simulator runs | macOS runner, `mcpp run --target aarch64-ios-sim` prints `1-2-3` | yes, through the `runner` and `xim:apple-simulator-tools`; and separately under a bare `simctl spawn`, which is what proved a bundle is not needed. Asserted by `ios-engine`, which fails when the exit status is not 0 or the line is absent | | D: the host surface is bounded | on a macOS runner with `xcode-select` pointing nowhere, both iOS rows fail with a message naming the SDK -- and no other row changes | THE CRITERION WAS WRONG AND WAS REPLACED. `DEVELOPER_DIR=/nonexistent` did not make the SDK unlocatable -- `xcrun` ignores an invalid developer directory and falls back -- so the iOS build SUCCEEDED and the step asserted nothing. The claim now lives where the SDK is genuinely absent: e2e 641 on every non-Apple host, with the refusal required to name the SDK, the `xcrun` command, the Command-Line-Tools note and the compiler, and to arrive before any payload is resolved | | E1: Android shares the implementation | merged and green against the RELEASED engine: both ABIs build, objects name no C library symbol, and a program over openkal alone ran on an emulator | yes (openkal-linux 0.12.0) | | E2: iOS reuses it | `openkal-macos` compiles for the three iOS rows on a macOS runner, and its objects name no C library symbol -- the same check the Android leg applies, against a third libc | the `cfg` line is in `examples/portable`; the compile leg belongs to openkal-macos's own CI and is not in this batch | @@ -729,6 +732,43 @@ file was written on, travelling as if it were a property of the scheme. The fourth is a field the caller sets and the implementation must honour, and the suite passes a deliberately short structure to find out. +### A verified tier whose only check could not fail + +The macOS job that measured the iOS rows was written as a probe. Every step +continued on error, because its first version stopped at the first unmet +premise and skipped the four measurements after it, and for a probe that was +correct. It stayed that way after those measurements moved `aarch64-ios-sim` to +`verified`, and a probe is not a gate: a regression in the Apple cross path +would have printed `RUN-THROUGH-RUNNER-FAILED` inside a job reported green, and +the tier would have gone on claiming a run that no longer happened. + +The final review found it by reading the workflow rather than its result, which +is the only way it could have been found. Every reading of that job was green. + +The job is now split along that distinction. `ios-host-surface` keeps the +premise measurements, continues on error, and runs only on request, because a +check that cannot fail, shown beside a gate, reads as a second gate. +`ios-engine` fails on four claims. Each of the three artefacts must be a Mach-O +of the row's architecture whose `LC_BUILD_VERSION` names the row's platform +(`2` for the device, `7` for both simulator rows) and the project's `minos`, +compared as whole values so that an empty reading fails. And +`mcpp run --target aarch64-ios-sim` must exit 0 with `1-2-3` as a whole line of +its output. + +The same review removed the fixture's three `toolchain = "llvm@22.1.8"` +overrides. They were needed while the rows were `planned`, and they meant the +job measured an override and never the rows' own pin, which is the path a +project that declares nothing takes. + +Two smaller findings from the same pass have the shapes already recorded +above. The located SDK path reaches three command lines and was quoted on two: +the std module's own command spliced it into a shell string unquoted, while +every other path in that string goes through `shq`, so an Xcode installed as +`Xcode 16.app` would have broken only the module precompile. And the payload +root joined `bin/` in the runner's search with a measurement behind it and no +test. The rule is now `runner_lookup::payload_search_dirs`, and a unit test +fails when it is reverted to `bin/` alone, which was checked by reverting it. + ## Gaps this batch recorded and did not close Each is an engine or package change with a measurement behind it, and each was diff --git a/.agents/docs/README.md b/.agents/docs/README.md index 76e82a0f..2890c62c 100644 --- a/.agents/docs/README.md +++ b/.agents/docs/README.md @@ -46,7 +46,7 @@ Records that declare one. Everything else is listed by date below. ### targets -- [SDK toolchains, the payload/engine seam, and openkal across iOS, Android and Web](2026-09-11-sdk-toolchains-and-ios-local-verification.md) — active +- [SDK toolchains, the payload/engine seam, and openkal across iOS, Android and Web](2026-09-11-sdk-toolchains-and-ios-local-verification.md) — landed - [Where a platform's knowledge belongs: iOS, Android and Web across the engine, the index and the plugins](2026-09-11-platform-targets-design-review.md) — active ### triage @@ -58,7 +58,7 @@ Records that declare one. Everything else is listed by date below. ### 2026-09 - [Six open issues: what each one actually is, and what would answer it](2026-09-11-six-open-issues-analysis.md) — active -- [SDK toolchains, the payload/engine seam, and openkal across iOS, Android and Web](2026-09-11-sdk-toolchains-and-ios-local-verification.md) — active +- [SDK toolchains, the payload/engine seam, and openkal across iOS, Android and Web](2026-09-11-sdk-toolchains-and-ios-local-verification.md) — landed - [Where a platform's knowledge belongs: iOS, Android and Web across the engine, the index and the plugins](2026-09-11-platform-targets-design-review.md) — active - [The category the plugin taxonomy does not name, and what a platform actually decomposes into](2026-09-11-distribution-plugins-and-platform-decomposition.md) — active - [Two answers and two silences: the scanner's second grammar, and the manifest keys nothing reads](2026-09-09-two-answers-and-two-silences.md) — active diff --git a/.github/workflows/ci-macos-ios.yml b/.github/workflows/ci-macos-ios.yml index 48674f5a..2bc2e285 100644 --- a/.github/workflows/ci-macos-ios.yml +++ b/.github/workflows/ci-macos-ios.yml @@ -25,26 +25,28 @@ concurrency: cancel-in-progress: true jobs: + # THE PREMISES, AS A PROBE THAT RUNS ON REQUEST. + # + # This job measured what the iOS rows were scheduled on: that a GitHub macOS + # runner ships both SDKs and a bootable simulator, that `simctl spawn` takes a + # bare Mach-O, that the payload's `clang++.cfg` names the macOS SDK, and + # which C++ runtime an iOS link can use. Every answer is now encoded -- in + # `simctl-run`, in `--no-default-config` on the Apple cross path, and in the + # MachO contract cell -- and asserted by `ios-engine` below. + # + # Every step continues on error, because a probe's value is the complete set + # of answers. That is also why it does not run on every change: a job that + # cannot fail shown as a green check beside the gate reads as a second gate. + # It stays available for the day a runner image changes one of the premises. ios-host-surface: name: iOS - what this runner actually provides + if: github.event_name == 'workflow_dispatch' runs-on: macos-15 timeout-minutes: 30 steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup-macos-llvm - # THE PREMISE, STATED AND THEN MEASURED. The design record schedules the - # iOS rows on one assumption: that a GitHub macOS runner ships both an - # iOS SDK and a bootable simulator. If it ships the SDK but no simulator, - # the device row is still verifiable here and the two simulator rows - # become a local-only claim -- which changes the tier they can reach and - # nothing else. This step is the difference between those two worlds. - # EVERY MEASUREMENT STEP CONTINUES, because the first version of this job - # did not and the four steps after the failing one were SKIPPED -- so one - # unmet assumption cost the whole measurement. A probe's value is the - # complete set of answers; a probe that stops at the first `no` is a - # build, not a measurement. (GitHub runs `run:` under `bash -e`, which is - # what turned a failing clang into a terminated step.) - name: "Host surface: the two located SDKs and the simulator runtime" continue-on-error: true run: | @@ -60,27 +62,15 @@ jobs: echo "--- devices available ---" xcrun simctl list devices available - # WHETHER A BARE MACH-O CAN BE RUN AT ALL, which decides how much the - # runner program has to do. `simctl launch` needs an installed .app - # bundle; `simctl spawn` takes an executable. If spawn works, the - # `simctl-run` program is a boot-and-spawn wrapper; if it does not, it - # has to synthesise a bundle, sign it and install it. Measuring this is - # cheaper than designing for the harder case. # THE PAYLOAD'S CLANG READS A CONFIG FILE, AND THAT CONFIG NAMES A - # DIFFERENT SDK. First run, with `-isysroot ` - # on the command line: + # DIFFERENT SDK. Measured with `-isysroot ` on + # the command line and the cfg not suppressed: # # ld64.lld: error: /Library/Developer/CommandLineTools/SDKs/ # MacOSX.sdk/usr/lib/libc++.tbd(/usr/lib/libc++.1.dylib) is # incompatible with arm64 (iOS Simulator18.0.0) # - # mcpp's own post-install writes the located macOS SDK into the - # payload's `clang++.cfg` so that a native macOS build is deterministic, - # and that cfg is read before the command line for search purposes. So - # an Apple CROSS target has to suppress it -- which mcpp's cross path - # already does (`--no-default-config` in hostflags.cppm), and which this - # hand-written probe did not. Printed here because the explanation - # belongs next to the measurement. + # which is why mcpp's Apple cross path carries `--no-default-config`. - name: "The payload's default config, which is why --no-default-config" continue-on-error: true run: | @@ -88,6 +78,9 @@ jobs: ls -la "$LLVM_ROOT/bin/"*.cfg || true for f in "$LLVM_ROOT/bin/"*.cfg; do echo "=== $f"; cat "$f"; done || true + # WHETHER A BARE MACH-O CAN BE RUN AT ALL, which decided how much the + # runner program has to do: `simctl launch` needs an installed .app, + # `simctl spawn` takes an executable. - name: "Device: is one bootable, and does spawn take a bare executable" continue-on-error: true run: | @@ -122,33 +115,11 @@ jobs: echo "SPAWN-FAILED exit=$?" fi - # THE DEVICE ROW'S ARTEFACT. Nothing runs it here -- that needs a - # signature the developer owns -- so the claim is about the ARTEFACT: - # arm64 Mach-O naming the iOS platform in LC_BUILD_VERSION. An artefact - # that says MACOS there is the failure this leg exists to catch, and it - # is invisible to a build that merely succeeds. - - name: "Device: the artefact names the iOS platform" - continue-on-error: true - run: | - set -euo pipefail - cat > /tmp/dev.cpp << 'CPP' - #include - int main() { std::puts("1-2-3"); return 0; } - CPP - SDK=$(xcrun --sdk iphoneos --show-sdk-path) - "$LLVM_ROOT/bin/clang++" -std=c++23 --no-default-config \ - -target arm64-apple-ios18.0 \ - -isysroot "$SDK" -o /tmp/dev /tmp/dev.cpp - file /tmp/dev - otool -l /tmp/dev | grep -A5 LC_BUILD_VERSION - - # THE C++ RUNTIME QUESTION, WHICH IS THE ONE THAT CAN SINK THIS. macOS - # links the PAYLOAD's static libc++ so that mcpp's deployment floor is - # real. That archive is built for macOS, and ld64 refuses an object built - # for one platform in a link for another -- so iOS may have to take - # libc++ from the SDK instead. Both are tried here, because "which one - # works" is the fact the flag builder needs and neither answer can be - # reasoned out from a Linux desk. + # THE C++ RUNTIME QUESTION. macOS links the PAYLOAD's static libc++ so + # that mcpp's deployment floor is real; that archive is built for macOS, + # and ld64 refuses an object built for one platform in a link for + # another. Both routes are tried because "which one works" is the fact + # the contract table needed. - name: "C++ runtime: SDK libc++ versus the payload static archive" continue-on-error: true run: | @@ -180,43 +151,13 @@ jobs: fi set +x - # `import std` FOR iOS, which is mcpp's default and therefore the real - # bar. The module is precompiled from the PAYLOAD's libc++ headers - # against the LOCATED SDK's C library -- the same split macOS already - # uses, with a second SDK. If this cannot be made to work the iOS rows - # are a non-module tier and the documentation has to say so. - - name: "import std: precompile against the located SDK" - continue-on-error: true - run: | - set -x - SDK=$(xcrun --sdk iphoneos --show-sdk-path) - STD_CPPM=$(find "$LLVM_ROOT" -name 'std.cppm' | head -1) - echo "std.cppm=$STD_CPPM" - MODDIR=$(dirname "$STD_CPPM") - if "$LLVM_ROOT/bin/clang++" -std=c++23 --no-default-config \ - -target arm64-apple-ios18.0 \ - -isysroot "$SDK" -Wno-reserved-module-identifier \ - -Xclang -emit-reduced-module-interface \ - --precompile -o /tmp/std.pcm "$STD_CPPM" -I"$MODDIR"; then - echo "PRECOMPILE-OK" - else - echo "PRECOMPILE-FAILED" - fi - cat > /tmp/mod.cpp << 'CPP' - import std; - int main() { std::println("1-2-3"); return 0; } - CPP - if "$LLVM_ROOT/bin/clang++" -std=c++23 --no-default-config \ - -target arm64-apple-ios18.0 \ - -isysroot "$SDK" -fmodule-file=std=/tmp/std.pcm \ - -o /tmp/mod /tmp/mod.cpp /tmp/std.pcm; then - echo "MODULE-LINK-OK" - else - echo "MODULE-LINK-FAILED" - fi - file /tmp/mod || true - set +x - + # THE GATE. Every step here fails the job when its claim does not hold. + # + # The first version of this job was a probe too -- every step continued on + # error -- and it stayed that way after the rows it measured moved to + # `verified` and `preview`. That left a verified tier with no check that + # could turn red: a regression in the Apple cross path would have printed + # `RUN-THROUGH-RUNNER-FAILED` inside a green job. ios-engine: name: iOS - mcpp builds and the simulator runs it runs-on: macos-15 @@ -233,16 +174,14 @@ jobs: "$MCPP" build echo "MCPP_DEV=$(ls -td "$PWD"/target/*/*/bin/mcpp | head -1)" >> "$GITHUB_ENV" - # THE THREE ROWS ARE `planned`, AND THAT IS THE STATE THIS JOB EXISTS TO - # CHANGE. A planned row is refused with its own escape hatch named: + # NO TOOLCHAIN IS DECLARED, AND THAT IS WHAT MAKES THIS THE USER'S PATH. # - # error: target 'aarch64-ios' is registered but not yet supported - # (planned) -- An explicit [target.aarch64-ios] toolchain - # override can opt in early. - # - # so the fixture writes that override. Once this job is green the tiers - # move and the override becomes unnecessary; keeping it until then is - # what makes the measurement possible before the claim is made. + # While the rows were `planned` the fixture had to write + # `[target.] toolchain = "llvm@22.1.8"` to get past the tier gate, + # which meant it measured an override and never the row's own pin. The + # rows are now `verified` and `preview` and resolve `llvm@22.1.8` by + # themselves, so the fixture says nothing and the default is what is + # measured. - name: "Fixture: a project that imports std and prints 1-2-3" run: | set -euo pipefail @@ -255,43 +194,21 @@ jobs: [build] ios_deployment_target = "18.0" - [target.aarch64-ios] - toolchain = "llvm@22.1.8" - - [target.aarch64-ios-sim] - toolchain = "llvm@22.1.8" - - [target.x86_64-ios-sim] - toolchain = "llvm@22.1.8" - - # D4: THE RUNNER IS AN ARGV PREFIX AND THE SESSION BELONGS TO A + # THE RUNNER IS AN ARGV PREFIX AND THE SESSION BELONGS TO A # PACKAGE. `simctl-run` comes from `xim:apple-simulator-tools`; it # chooses a device, boots it if it is not booted, waits, spawns, and - # returns the program's own exit status. The engine learns nothing - # about simulators, which is the boundary it keeps for every other - # emulated target. - # - # The device row keeps `runner` unset: an artefact cannot be run off - # an iOS device without a signature the developer owns. - # - # THE TOOL IS DECLARED AT THE TOP LEVEL HERE AND NOT IN - # examples/13, and the difference is which hosts the manifest has to - # work on. A tool declaration is not conditional on a target -- - # measured: "[target.aarch64-ios-sim.xlings] does not accept 'deps'" - # -- and `xim:apple-simulator-tools` exists for macosx alone, so an - # unconditional declaration breaks a Linux build. This fixture is - # macOS-only, so it can say it. - # - # `[xlings.workspace]` and not `[xlings] deps`, which mcpp reports as - # superseded: "deps is superseded by [xlings.workspace] and will stop - # being read. It is honoured for now." - [xlings.workspace] - "xim:apple-simulator-tools" = "" - TOML - cat >> /tmp/iostest/mcpp.toml << 'TOML' - + # returns the program's own exit status. The device row keeps + # `runner` unset: an artefact cannot be run off an iOS device without + # a signature the developer owns. [target.aarch64-ios-sim] runner = ["simctl-run"] + + # Declared at the top level here and not in examples/13, because a + # tool declaration is not conditional on a target and this package + # exists for macOS alone. This fixture is macOS-only, so it can say + # it; a portable manifest cannot. + [xlings.workspace] + "xim:apple-simulator-tools" = "" TOML cat > /tmp/iostest/src/main.cpp << 'CPP' import std; @@ -303,117 +220,90 @@ jobs: CPP cat /tmp/iostest/mcpp.toml - # D1/D2: THE DEVICE ROW'S ARTEFACT. Nothing runs it -- that needs a - # signature the developer owns -- so the claim is about the artefact: - # arm64 Mach-O naming the iOS platform and the project's deployment - # target in LC_BUILD_VERSION. A build that merely succeeds cannot tell - # those apart from a macOS binary. - - name: "D1: mcpp build --target aarch64-ios" - continue-on-error: true + # ONE ASSERTION FOR THE THREE ARTEFACTS. A build that succeeds cannot + # tell an iOS binary from a macOS one; LC_BUILD_VERSION can. Each + # value is read from the load command and compared whole, so an empty + # reading is a failure and not a pass. + cat > /tmp/assert-artefact.sh << 'SH' + #!/usr/bin/env bash + set -uo pipefail + target=$1 arch=$2 platform=$3 minos=$4 + arts=(/tmp/iostest/target/"$target"/*/bin/iostest) + art=${arts[0]} + if [ ! -f "$art" ]; then + echo "FAIL: $target produced no artefact under /tmp/iostest/target/$target" + exit 1 + fi + desc=$(file "$art") + lc=$(otool -l "$art") + echo "$desc" + grep -A5 LC_BUILD_VERSION <<<"$lc" || true + got_platform=$(awk '/cmd LC_BUILD_VERSION/{f=1} f && $1=="platform"{print $2; exit}' <<<"$lc") + got_minos=$(awk '/cmd LC_BUILD_VERSION/{f=1} f && $1=="minos"{print $2; exit}' <<<"$lc") + fail=0 + if ! grep -qE "Mach-O 64-bit executable ${arch}\$" <<<"$desc"; then + echo "FAIL: $target is not a Mach-O $arch executable"; fail=1 + fi + if [ "$got_platform" != "$platform" ]; then + echo "FAIL: $target LC_BUILD_VERSION platform is '$got_platform', expected $platform"; fail=1 + fi + if [ "$got_minos" != "$minos" ]; then + echo "FAIL: $target minos is '$got_minos', expected $minos from ios_deployment_target"; fail=1 + fi + [ "$fail" = 0 ] && echo "ok: $target is Mach-O $arch, platform $platform, minos $minos" + exit "$fail" + SH + chmod +x /tmp/assert-artefact.sh + + # THE DEVICE ROW. Nothing runs it -- that needs a signature the developer + # owns -- so the claim is the artefact: `platform 2` is IOS. The refusal + # for a machine WITHOUT the SDK is asserted by tests/e2e/641 on every + # non-Apple host, which is the only place the SDK is genuinely absent: + # pointing `DEVELOPER_DIR` at nothing here measured nothing, because + # `xcrun` falls back to the recorded developer directory. + - name: "aarch64-ios: the artefact names the iOS platform" run: | - set -x + set -euo pipefail cd /tmp/iostest "$MCPP_DEV" build --target aarch64-ios - set +x - ART=$(find /tmp/iostest/target/aarch64-ios -name iostest -type f | head -1) - echo "artefact=$ART" - file "$ART" - otool -l "$ART" | grep -A5 LC_BUILD_VERSION + /tmp/assert-artefact.sh aarch64-ios arm64 2 18.0 - # D4's PREMISE: whether `simctl spawn` takes a bare Mach-O. If it does, - # the `simctl-run` program in xim:apple-simulator-tools is a - # boot-and-spawn wrapper; if it does not, it has to synthesise a bundle, - # sign it and install it. This is the measurement that decides which. - - name: "D4: mcpp build --target aarch64-ios-sim, then simctl spawn" - continue-on-error: true + # `platform 7` is IOSSIMULATOR. The number is what separates this row from + # the device row; the architecture does not. + - name: "aarch64-ios-sim: the artefact names the simulator platform" run: | - set -x + set -euo pipefail cd /tmp/iostest "$MCPP_DEV" build --target aarch64-ios-sim - set +x - ART=$(find /tmp/iostest/target/aarch64-ios-sim -name iostest -type f | head -1) - echo "artefact=$ART" - file "$ART" - otool -l "$ART" | grep -A5 LC_BUILD_VERSION - UDID=$(xcrun simctl list devices available \ - | grep -A50 -- '-- iOS' \ - | grep -m1 -oE '[0-9A-F]{8}-[0-9A-F-]{27}' || true) - echo "udid=[$UDID]" - if [ -z "$UDID" ]; then echo "NO-IOS-SIMULATOR-DEVICE"; exit 0; fi - xcrun simctl boot "$UDID" || true - xcrun simctl bootstatus "$UDID" -b 2>&1 | tail -3 || true - if xcrun simctl spawn "$UDID" "$ART"; then - echo "SPAWN-OK" - else - echo "SPAWN-FAILED exit=$?" - fi + /tmp/assert-artefact.sh aarch64-ios-sim arm64 7 18.0 - # D4: `mcpp run` THROUGH THE RUNNER, WHICH IS THE SUPPORTED PATH. - # - # The step above spawned the artefact by hand, which proves the platform - # can run it and not that mcpp can. This repository has a standing note - # that publishing and verifying an asset is not the same as being able to - # use it through a supported path, and the difference here is a `runner` - # a manifest declares and a program a package provides. - - name: "D4: mcpp run --target aarch64-ios-sim prints 1-2-3" - continue-on-error: true + # THE SUPPORTED PATH, which is what the `verified` tier claims: a runner + # the manifest declares and a program a package provides. The program's + # own line is compared whole. An iOS-simulator Mach-O does not execute on + # the macOS host directly, so the line appearing at all means the + # simulator ran it. + - name: "aarch64-ios-sim: mcpp run prints 1-2-3 through the runner" run: | - set -x + set -uo pipefail cd /tmp/iostest - out=$("$MCPP_DEV" run --target aarch64-ios-sim 2>&1) || true - set +x + out=$("$MCPP_DEV" run --target aarch64-ios-sim 2>&1) && rc=0 || rc=$? printf '%s\n' "$out" | tail -20 - if printf '%s\n' "$out" | grep -q '1-2-3'; then - echo "RUN-THROUGH-RUNNER-OK" - else - echo "RUN-THROUGH-RUNNER-FAILED" + if [ "$rc" -ne 0 ]; then + echo "FAIL: mcpp run --target aarch64-ios-sim exited $rc" + exit 1 + fi + if ! grep -qx '1-2-3' <<<"$out"; then + echo "FAIL: the program's output line '1-2-3' is absent" + exit 1 fi + echo "ok: mcpp run --target aarch64-ios-sim printed 1-2-3 and exited 0" - # x86_64-ios-sim IS THE THIRD ROW AND THIS RUNNER CANNOT RUN IT. - # - # The simulator runs the HOST's architecture, so an Apple-silicon machine - # has no x86_64 iOS runtime to spawn into. What can be measured here is - # the ARTEFACT, which is the claim the row's tier will rest on; a machine - # with an Intel host is what would move it further. + # THE THIRD ROW, ON A HOST THAT CANNOT RUN IT. A simulator runs the host's + # architecture and this runner is Apple silicon, so the claim is the + # artefact alone -- which is exactly the `preview` tier the row carries. - name: "x86_64-ios-sim: the artefact, on a host that cannot run it" - continue-on-error: true run: | - set -x + set -euo pipefail cd /tmp/iostest "$MCPP_DEV" build --target x86_64-ios-sim - set +x - ART=$(find /tmp/iostest/target/x86_64-ios-sim -name iostest -type f | head -1) - file "$ART" - otool -l "$ART" | grep -A5 LC_BUILD_VERSION - - # THE HOST SURFACE IS BOUNDED, AND ITS ABSENCE NAMES THE SDK. The - # recorded rule is that a host dependency must be minimal, named, and - # never a fallthrough -- so the refusal is a claim like any other, and - # `SDKROOT` pointing at a macOS SDK is the cheapest way to make the iOS - # SDK unlocatable without breaking the rest of the machine. - # - # AND NOT BY POINTING `DEVELOPER_DIR` AT NOTHING, WHICH MEASURED NOTHING. - # - # The first version of this step set `DEVELOPER_DIR=/nonexistent` and - # expected the refusal. The build SUCCEEDED: `xcrun` ignores an invalid - # developer directory and falls back to the recorded one, so the - # environment change did not make the SDK unlocatable. The predicate was - # right and the object was wrong -- the claim was never tested. - # - # The claim now lives where the SDK is genuinely absent, which is every - # non-Apple host: `tests/e2e/641` asserts it on Linux and Windows, with - # an explicit toolchain override so the tier gate does not answer first - # and `MCPP_NO_AUTO_INSTALL=1` so no payload is needed. What is left for - # this runner is the other half of the same claim -- that a machine which - # DOES have the SDK is not refused -- and that is what the steps above - # measure by building. - - name: "D: the SDK is located here, which is the other half of the claim" - continue-on-error: true - run: | - set -x - xcrun --sdk iphoneos --show-sdk-path - xcrun --sdk iphonesimulator --show-sdk-path - set +x - echo "both SDKs located; the refusal for a machine without them is" - echo "asserted by tests/e2e/641 on every non-Apple host" - cd /tmp/iostest && "$MCPP_DEV" build --target aarch64-ios 2>&1 | tail -3 + /tmp/assert-artefact.sh x86_64-ios-sim x86_64 7 18.0 diff --git a/src/build/execute.cppm b/src/build/execute.cppm index 0ca19e7d..67d88260 100644 --- a/src/build/execute.cppm +++ b/src/build/execute.cppm @@ -1713,7 +1713,7 @@ export int build_run_target(const std::optional& targetName, } if (!choice.tmpl.empty()) { // The program is located by mcpp, not by posix_spawnp: a declared - // payload's bin/ first, then PATH — see runner_lookup for the shim + // payload's bin/ and then its root, then PATH — see runner_lookup for the shim // measurement that makes the order matter. Not found anywhere is // decided here, before any spawn, and is an error rather than a // fallback to bare execution (#544, D1): running the artifact under a diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index f5c0c327..925b4fff 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -80,6 +80,7 @@ import mcpp.platform.runtime_search; import mcpp.toolchain.post_install; import mcpp.platform; import mcpp.platform.macos; +import mcpp.build.runner_lookup; import mcpp.fetcher; import mcpp.fetcher.progress; import mcpp.pm.resolver; @@ -807,8 +808,9 @@ export struct BuildContext { // derivation would drift from the first exactly when a resolution rule // changes. Written into `.build_cache`; see BuildCacheEntry::depSourceRoots. std::vector depSourceRoots; - // `/bin` of every installed `[xlings] deps` payload of the - // runtime-owner manifest, in declaration order (#544). Read by + // `/bin` and then `` of every installed `[xlings] deps` + // payload of the runtime-owner manifest, in declaration order (#544); the + // pair comes from runner_lookup::payload_search_dirs. Read by // choose_runner's lookup (mcpp.build.runner_lookup) so a runner may name // a program the project declared without writing the payload's // home-and-version path into the manifest. Computed by the same @@ -3616,9 +3618,15 @@ prepare_build(bool print_fingerprint, // SDK here the precompile resolves libc++'s // `#include <__config>` against the macOS SDK and the // module is built for the wrong platform. + // + // QUOTED, as every path this string carries is (see the + // package-provided producer, which uses `shq` for each + // `-isystem`). The string is spliced into a shell + // command, and an Xcode installed as `Xcode 16.app` is + // a path with a space in it. tc->stdModuleTargetFlags = " " + tc->crossTargetFlag - + " -isysroot " + sdk->string(); + + " -isysroot " + mcpp::xlings::shq(sdk->string()); } } } @@ -10432,27 +10440,12 @@ prepare_build(bool print_fingerprint, for (auto const& spec : xlingsSpecs) { auto ref = mcpp::xlings::paths::parse_xpkg_ref(spec); if (auto dir = mcpp::xlings::paths::xpkg_payload(xlEnv, ref)) { - ctx.xlingsDepBinDirs.push_back(*dir / "bin"); - // AND THE PAYLOAD ROOT, BECAUSE A FLAT LAYOUT IS A - // LAYOUT THIS INDEX ALREADY SHIPS. - // - // `bin/` is the convention and stays first. It is not - // universal: `xim:7zip` puts `7zz` straight into its - // install directory, and so did the first version of - // `xim:apple-simulator-tools` -- which is how this was - // measured, on a macOS runner with the package - // correctly installed: - // - // error: runner 'simctl-run' for 'aarch64-ios-sim' - // was not found on any search path. - // Searched: .../xim-x-apple-simulator-tools/0.1.0/bin - // - // The directory searched was right and the program was - // one level up. Two directories per package is cheaper - // than a rule every recipe has to know, and a recipe - // that does use `bin/` is unaffected because that entry - // is still tried first. - ctx.xlingsDepBinDirs.push_back(*dir); + // `bin/`, then the payload root. The measurement that + // added the second entry is recorded with the rule, in + // runner_lookup::payload_search_dirs. + for (auto& d : + mcpp::build::runner_lookup::payload_search_dirs(*dir)) + ctx.xlingsDepBinDirs.push_back(std::move(d)); } } } diff --git a/src/build/runner_lookup.cppm b/src/build/runner_lookup.cppm index d9e4bb0c..09c1d61f 100644 --- a/src/build/runner_lookup.cppm +++ b/src/build/runner_lookup.cppm @@ -46,6 +46,28 @@ inline bool executable_file(const std::filesystem::path& p) { } } // namespace detail +// The directories one installed payload contributes to `locate`, in order: +// `/bin`, then `` itself. +// +// `bin/` is the convention and stays first. It is not universal: `xim:7zip` +// installs `7zz` straight into its payload directory, and so did the first +// version of `xim:apple-simulator-tools` -- which is how the second entry was +// measured, on a macOS runner with that package correctly installed: +// +// error: runner 'simctl-run' for 'aarch64-ios-sim' was not found on any +// search path. +// Searched: .../xim-x-apple-simulator-tools/0.1.0/bin +// +// The directory searched was right and the program was one level up. Two +// directories per payload is cheaper than a layout rule every recipe has to +// know, and a recipe that uses `bin/` is unaffected because that entry is +// still tried first. A function rather than two `push_back`s at the call site +// so that the order is a stated rule a unit test can hold. +inline std::vector +payload_search_dirs(const std::filesystem::path& payloadRoot) { + return { payloadRoot / "bin", payloadRoot }; +} + // `argv0` absolute, or containing a directory separator: taken as-is when it // is an executable file. Otherwise `/argv0`, then each `PATH` // entry (`pathEnv` split on the platform's list separator); the first diff --git a/tests/matrix/scan.sh b/tests/matrix/scan.sh index fb67c8e7..2d03ad7c 100755 --- a/tests/matrix/scan.sh +++ b/tests/matrix/scan.sh @@ -180,7 +180,7 @@ for tc in $(compilers); do # 目标上两者同串,所以 openkal-musl 的 CI 从来没有见过这条;它**从未为任何 # Apple 目标构建过**。当前的 0.13.1 仍是同一种写法。 # - # ⚠️ macOS 行**没有**被略过,而这正是这条注释必须说清的地方: + # macOS 行**没有**被略过,而这正是这条注释必须说清的地方: # `graph × aarch64-macos` 实测是 `ok / none`。所以这不是「Apple 行不能由图 # 供给」——那句话会被 macOS 当场否掉——而是「这套依赖钉的那个版本服务不了 # iOS 行」。判据是那条诊断,不是这一族目标。 diff --git a/tests/unit/test_runner_lookup.cpp b/tests/unit/test_runner_lookup.cpp index 2445ac11..0f5a5f16 100644 --- a/tests/unit/test_runner_lookup.cpp +++ b/tests/unit/test_runner_lookup.cpp @@ -113,6 +113,31 @@ TEST(RunnerLookup, UnrunnableMessageNamesKernelAnswerTripleAndKey) { EXPECT_NE(msg.find("--no-runner"), std::string::npos) << msg; } +// A PAYLOAD CONTRIBUTES TWO DIRECTORIES, AND THE ORDER IS PART OF THE RULE. +// The flat case is the one measured missing on a macOS runner: a program at +// the payload root with no `bin/`, reached through exactly the directories +// prepare collects for each installed payload. +TEST(RunnerLookup, AFlatPayloadIsFoundAtItsRoot) { + auto root = fresh_root("mcpp-runner-lookup-flat"); + auto flat = make_exe(root / "payload", "simctl-run"); + auto l = locate("simctl-run", payload_search_dirs(root / "payload"), ""); + ASSERT_TRUE(l.program.has_value()); + EXPECT_EQ(*l.program, flat); + ASSERT_EQ(l.searched.size(), 2u); + EXPECT_EQ(l.searched[0], root / "payload" / "bin"); + EXPECT_EQ(l.searched[1], root / "payload"); +} + +// And a recipe that follows the convention is unaffected by the second entry. +TEST(RunnerLookup, APayloadsBinIsTriedBeforeItsRoot) { + auto root = fresh_root("mcpp-runner-lookup-binfirst"); + auto inBin = make_exe(root / "payload" / "bin", "tool"); + make_exe(root / "payload", "tool"); + auto l = locate("tool", payload_search_dirs(root / "payload"), ""); + ASSERT_TRUE(l.program.has_value()); + EXPECT_EQ(*l.program, inBin); +} + TEST(RunnerLookup, SpawnFailedMessageIsVerbatim) { auto msg = spawn_failed_message("/x/bin/qemu", EACCES); EXPECT_NE(msg.find("'/x/bin/qemu' could not be started"), std::string::npos) << msg; From 3782d38fb7f7994fbc1df546d50cf082dd3ca727 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:53:19 +0800 Subject: [PATCH 14/14] docs(changelog): 2026.9.11.4 The release notes are extracted from the entry whose heading names the version, and without one the release would publish "(no CHANGELOG entry found for 2026.9.11.4)". Found during the pre-release review. --- CHANGELOG.md | 82 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dcf27e6c..fb59c1c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,88 @@ ## [Unreleased] +## [2026.9.11.4] - 2026-09-11 + +### iOS 三行:生态编译器与定位到的 SDK + +`aarch64-ios`、`aarch64-ios-sim`、`x86_64-ios-sim` 从 `planned` 进入可构建状态, +钉 `llvm@22.1.8`。编译器来自生态,只有 SDK 来自本机 Xcode:iPhoneOS 与 +iPhoneSimulator SDK 不可再分发,所以 mcpp **定位**它们而不安装 +(`xcrun --sdk --show-sdk-path`)。2026-09-11 在 macos-15 runner 上实测: + +``` +aarch64-ios Mach-O 64-bit executable arm64 platform 2 (IOS) minos 18.0 preview +aarch64-ios-sim Mach-O 64-bit executable arm64 platform 7 (IOSSIMULATOR) minos 18.0 verified +x86_64-ios-sim Mach-O 64-bit executable x86_64 platform 7 (IOSSIMULATOR) minos 18.0 preview +``` + +`mcpp run --target aarch64-ios-sim` 经 `runner = ["simctl-run"]` 打印 `1-2-3`。 +`simctl-run` 来自新包 `xim:apple-simulator-tools`,负责选设备、启动、等待与 +`simctl spawn`;引擎不认识模拟器。设备行不设 runner,因为在设备上运行需要开发者 +自己的签名。`x86_64-ios-sim` 停在 `preview`:模拟器运行宿主的架构,而测量用的 +runner 是 Apple silicon。 + +- 新键 `[build] ios_deployment_target`,由有效三元组携带 + (`arm64-apple-ios18.0-simulator`),不另发 `-m*-version-min`。 +- SDK 缺席在解析任何载荷**之前**拒绝,拒绝码 `apple-sdk-absent`。没有包能提供 + 不可再分发的 SDK,所以这条拒绝不等待依赖图。 +- Apple 交叉路径在编译行、链接行与 std 模块自己的命令上携带 `--target`、 + `-isysroot` 与 `--no-default-config`。载荷的 `clang++.cfg` 写的是 macOS SDK, + 不抑制它,链接会从错误平台的桩库解析 `libc++`。 +- C++ 运行时取自 SDK(`-lc++`)。载荷的 `libc++.a` 为 macOS 构建,ld64 拒绝把它 + 链进 iOS 链接。 + +### `aarch64-linux-android` 从 `preview` 到 `verified` + +执行路径本来就是对的,坏的只是抽取工具:`xim:e2fsprogs` 的 `debugfs` 在任何打开 +文件系统的命令上 SIGFPE,而 `xim:7zip` 直接读取 ext4。`qemu-aarch64-static -L ` +配系统镜像自带的 bionic 运行 mcpp 构建的产物,打印 `1-2-3`,exit 0。 + +### 载荷描述自身:`.mcpp-toolchain.json` + +安装配方在载荷根目录写一份描述(schema 1),引擎读取三个键:`frontend`、 +`platform_floor`、`std_module_defines`。这三件 NDK 专属的事实原先由引擎重新推导, +于是第二个同类 SDK 意味着修改引擎而不是发布一个包。 + +- 缺席即兼容:没有描述的载荷,行为与之前完全相同。 +- 存在但格式错误则拒绝,并点名该文件。 +- `frontend` 按字符串校验(相对路径、`/` 分隔、无 `.` 或 `..` 分量、无盘符)。 + `std::filesystem::path("/usr/bin/g++").is_absolute()` 在 Windows 上为 false, + 按路径类型判断会让一个包在 Windows 上选中宿主编译器。 +- 它不是通用 flag 通道:`std_module_defines` 的每一项必须是 define 名。 + +`xim:android-ndk` 已写出这份描述。 + +### 修复 + +- 撤回 `ndk` 别名。它能被解析,随后又被能力钉拒绝;唯一拼写是索引使用的 + `android-ndk`。 +- `mcpp toolchain install emsdk` 与 `mcpp toolchain install android-ndk` 此前从未 + 成功:安装路径自行拼接 `/bin`,且 `to_xim_package` 读取目标而不是 spec 点名 + 的载荷。 +- `em++` 旁边的 C 编译器被推导成 `em`。推导改为表驱动:`g++` 对应 `gcc`,`em++` + 对应 `emcc`,其余去掉 `++`。 +- `host_can_serve` 判断的是 `os == "macos"`,于是 iOS 行在 macOS 上从 + `toolchain list` 消失,在 Linux 上又因错误的理由出现。 +- runner 程序的查找在 `bin/` 之后加入载荷根目录,`xim:7zip` 即是这种布局。 +- iOS 的 std 模块命令对 SDK 路径加引号。Xcode 安装在含空格的路径下时,原先只有 + 模块预编译会失败。 + +### CI + +- `ci-macos-ios` 的 `ios-engine` 是门禁:三个产物的架构、`LC_BUILD_VERSION` 的 + platform 与 minos 按整值比较,空读数判为失败;`mcpp run --target aarch64-ios-sim` + 必须退出 0 且输出中有 `1-2-3` 这一行。前提探针只在手动触发时运行。 +- `check_target_tiers.py` 把层级表与五份文档对照,单元测试里的层级断言也在其中。 +- 目标矩阵增加 `apple-sdk-absent`,iOS 行只在 `macos-arm64` 上有期望值。 + +### 生态 + +- `mcpplibs/openkal-emscripten` 0.1.0:第一个写在 C 库之上的 openkal 实现。一致性 + 套件 86 项成立、0 项不成立、13 项未观测;缺席的接口组在链接期以符号名失败。 +- `openxlings/xim-pkgindex`:`android-system-image` 改用 `xim:7zip`,`android-ndk` + 写出描述文件,新增 `apple-simulator-tools`。 + ## [2026.9.11.3] - 2026-09-11 ### `wasm32-emscripten` 从 `planned` 到 `verified`