Skip to content

feat(platform): iOS support through cdylib - #663

Draft
Guikingone wants to merge 19 commits into
illegalstudio:mainfrom
Guikingone:feat/ios-target
Draft

feat(platform): iOS support through cdylib#663
Guikingone wants to merge 19 commits into
illegalstudio:mainfrom
Guikingone:feat/ios-target

Conversation

@Guikingone

@Guikingone Guikingone commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Closes #662.

Makes iOS a real compilation target. Compiled PHP runs on the iOS Simulator:

==> running inside booted simulator 9A3F533A-…
    42 hi iOS 6

iPhone 17 Pro, iOS 26.5, arm64. spike_add(40, 2) returned 42, spike_greet("iOS", 3) returned "hi iOS" with length 6, and elephc_free released the buffer — the int export, the string export and the ownership contract, end to end on device-class arm64.

The architecture decision

iOS is a variant on Target, not a fourth Platform.

Every syscall number, struct offset, errno, open flag and Mach-O convention is identical between macOS and iOS on arm64 — they share XNU. A new Platform would have grown ~181 match arms across 54 files that all return exactly what macOS already returns, and turned every future OS constant into a four-way match.

The evidence that this was right: Target::supports_current_backend() needed no change at all. The arm64 Darwin backend serves iOS unchanged, and the central gate at pipeline.rs never had to open.

What genuinely differs turned out to be four things:

SDK selection linker/sdk.rs now always passes --sdk explicitly. Relying on xcrun's default resolves whatever the selected developer directory offers, silently handing back the macOS SDK when the requested iOS one is missing
-platform_version iOS records a 13.0 deployment floor; macOS keeps its report-the-SDK-version behaviour, untouched
Compiler triple native_deps/toolchain.rs matched a fixed darwin and therefore rejected every iOS toolchain outright
Sandbox rules six builtins depend on fork and cannot work — see below

Target::as_str() encodes the variant because three persisted keys derive from it: the runtime object cache filename, the native-dependency ArtifactReceipt JSON, and the package catalog. A macOS and an iOS build sharing a string would silently reuse each other's artifacts. A test pins that macOS, device and simulator cannot collide and that each string round-trips.

A missing iOS SDK no longer suggests xcode-select --install — the Command Line Tools that installs ship no iOS SDK, so the advice loops.

What's in it

String returns from #[Export] — the gate everything else waited on

is_v1_return_type accepted Int | Float | Bool | Void and no Str, so an embedded elephc library could only ever return a number. Every realistic payload — a serialized view tree, a JSON result, a domain object — is a string.

Ownership. Strings are move-semantics, not refcounted: __rt_decref_any sends kind 1 straight to __rt_heap_free_safe, which frees rather than decrements. An exported string return must therefore be exactly one owned heap block. Lowering now persists it unconditionally, and that persist replaces the borrow acquire rather than stacking on it — Str is lifetime-tracked, so acquiring and then copying would strand the acquired reference as a leak. Without the forced persist, return $param; hands the host back its own pointer and return "literal"; a .rodata address, neither of which elephc_free may legitimately release.

Two register contracts, both silent failure modes:

  • AAPCS64 returns a 16-byte aggregate in x0/x1 while elephc produces the pair in x1/x2. The trampoline shifts it down and can no longer tail-branch. x86_64 needs no shift — rax/rdx already is the SysV two-INTEGER struct return.
  • elephc_free receives its argument in rdi under SysV, but __rt_heap_free_safe reads rax. x86_64 bridges the register; AArch64 passes it in x0 and tail-branches unchanged.

Neither side is covered by the other's tests. Left alone this would have shipped as silent corruption on precisely the arm64 path iOS depends on.

The in-band NULL_SENTINEL is translated to a real C NULL with a zero length at the boundary, branchlessly.

--emit staticlib

lib<stem>.a with the user object, the runtime object and a symbol index. A C host links it directly — no dlopen — and runs. This is the delivery form an Xcode project consumes; a .a also avoids the embedded-framework signing dance a .dylib in an app bundle requires.

It is deliberately not the PIC path. Emitter::new_pic exists for dynamic loading, where the loader resolves cross-object references at dlopen time; its GOT indirection is unrelated to position independence as such. An archive is merged once into the host's final binary by the host's own linker, exactly like the executable path — whose non-PIC output is already PC-relative and already yields PIE binaries. The test proves it: the archive links into a PIE host and works.

An Emit::is_library() predicate replaces the scattered matches!(emit, Emit::Cdylib) checks the two library kinds share. The one Cdylib-only check stays Cdylib-only: ELF dynamic-symbol visibility is meaningless for a .a. Both linker command renderers get explicit unreachable! arms rather than a catch-all, so a future emit kind cannot silently inherit link behaviour. Archiving bypasses the link plan: bridges and managed native packages stay separate .a files for the consuming project, as a C library leaves its dependencies to its consumer.

Capability gating

system, passthru, exec, shell_exec, popen and pclose are refused at compile time for iOS targets:

error[1:7]: shell_exec() cannot be compiled for ios-arm64: that sandbox forbids
spawning a process, so the call would always fail at run time

The same source still compiles for the host — this is a target capability gate, not a removal of the builtin.

The gate lives in each builtin's check: hook rather than in a backend audit, because a hook has the call's exact span. An exhaustive-match audit would guarantee no builtin is forgotten, but the WASM equivalent shows the price: its errors can only cite block and instruction indices, having no wiring to CompileError/Span. proc_open and its family are absent because they do not exist in the compiler at all; the guard's doc comment records that they must adopt it when added.

Assembling for the right platform

Running Lot 0 against a real SDK found the one thing that could not be predicted from the macOS side:

ld: building for 'iOS-simulator', but linking in object file built for 'macOS'

A Mach-O object records the platform it was assembled for. as -arch arm64 stamps macOS and cannot say otherwise, so relinking against the iOS SDK was never going to be enough — an earlier note in this PR claiming only -syslibroot and -platform_version remained untested was wrong.

Non-macOS Apple targets now assemble through clang with -target and -isysroot. The user object and the cached runtime object built that command in two separate places and would have drifted; they now share one linker::assembler_command, because they must agree or the link fails on whichever one disagrees. APPLE_IOS_MIN_OS is a single constant for the same reason: the assembler writes it into each object's LC_BUILD_VERSION and the linker into the image's -platform_version, and a mismatch between those is itself a link error.

macOS is byte-unchanged — its objects still report platform MACOS and a statically linked host still runs.

Native dependencies

pcre2 and zlib are catalogued for ios-arm64 and ios-sim-arm64, so any PHP program reaching PCRE or zlib can build them for iOS. Both are plain autoconf/make projects with no host assumptions beyond a C compiler, so neither needed a per-package caveat. resolve_toolchain already refuses any cross target without explicit ELEPHC_NATIVE_CC/AR/RANLIB; for iOS those must carry -target and -isysroot or configure silently probes the host.

Adding the entries exposed two correctness bugs:

  • validate_tuple accepted a simulator compiler for a device target, and vice versa. The two triples differ only by a trailing -simulator, so the substring match let incompatible objects through. The accepted spellings are measured, not assumed: clang -dumpmachine reports arm64-apple-ios13.0 under -target arm64-apple-ios13.0, and arm64-apple-darwin25.5.0 with no target flag.
  • resolve_toolchain hardcoded --sdk macosx when reading the SDK version into the toolchain fingerprint, so an iOS build recorded the host SDK — and bumping the iOS SDK never invalidated the cache.

Device and simulator artifacts do not collide despite sharing an abi string: ArtifactKey carries target.as_str() as its own path component, which the Apple variant already distinguishes.

The lockfile test asserted a hardcoded count of three target plans. It now derives the expectation from the catalog and checks the target names, so adding a target is not a chore and the assertion says what it means.

Exercising the entries against a real SDK found a third bug, and it would have made the catalog claim hollow. The recipes passed toolchain.target_tuple — whatever clang -dumpmachine prints — as --host/CHOST. autoconf feeds those through config.sub, which parses an Apple cross triple as kernel ios13.0 plus OS simulator and rejects it outright:

Invalid configuration 'arm64-apple-ios13.0-simulator':
Kernel 'ios13.0' not known to work with OS 'simulator'.

toolchain.abi is elephc's normalized identity and is exactly what config.sub wants. Measured against a real one: arm64-apple-ios13.0-simulator rejected, aarch64-apple-ios and aarch64-apple-darwin accepted. Linux is unaffected in substance — its abi is the canonical <arch>-unknown-linux-<gnu|musl> form of the same information.

pcre2 and zlib now build for ios-sim-arm64, with archive members reporting platform IOSSIMULATOR, arch arm64, cached under ios-sim-arm64/aarch64-apple-ios. The host macOS build still produces MACOS members.

One note for consumers: ELEPHC_NATIVE_CC_* takes a command path, not a command line, so an iOS toolchain needs a small wrapper script carrying -target and -isysroot.

scripts/ios-relink-spike.sh

Answers the platform question in one command once Xcode is installed: emit assembly, assemble, relink against the iOS SDK, build a C host for the simulator triple and run it through simctl spawn — no Xcode project involved.

The chain minus the SDK swap is already verified: run against the macOS SDK it returns 42 hi iOS 6, exercising the int export, the string export and elephc_free. Only -syslibroot and -platform_version remain untested. It isolates XDG_CACHE_HOME deliberately — the runtime object's cache key encodes the program's feature set, so the shared cache holds several candidates and a newest-match glob would link the wrong one.

examples/swiftui-view-protocol/

A native app whose entire interface is decided by compiled PHP: render_view() returns a serialized view tree, dispatch() returns the next one after an event, and the Swift host understands four node types and nothing else.

It runs on macOS and iOS from the same view.php and the same Swift. run-ios.sh builds a real iOS app, installs it on a booted simulator, launches it and screenshots the result — no .xcodeproj involved, swiftc and the simulator SDK are enough. The library is linked statically, the delivery form an Xcode project consumes; that is also what lets one Swift source serve both platforms, dlopen being the wrong shape for iOS.

This is the shape that fits ahead-of-time compilation: a template engine must evaluate itself on the device and so needs a runtime there, while a tree generator compiles once and ships as machine code. It is the one corner of the UI problem where being AOT costs nothing.

Two constraints any Swift host will hit, documented in its README so the iOS host does not rediscover them: ElephcStr must be declared in C (Swift rejects a Swift-declared struct in a @convention(c) signature — only a C type carries the guarantee that the value rides the aggregate-return registers), @main requires -parse-as-library, and -sdk does not reach the link stepswiftc drives clang to link with the host sysroot unless -Xclang-linker -isysroot passes the SDK through, which an iOS build otherwise reports as "using sysroot for 'MacOSX' but targeting 'iPhone'".

Verification

Focused, per AGENTS.md; CI owns the matrix.

suite
cdylib_tests 6/6 — string-return ownership, staticlib link-and-run, iOS capability gating
native_deps 56 / 0
error_tests 1139 / 0
codegen::oop 585 / 0
ir_backend_smoke_test 255 / 0 — catches compilation errors
codegen::strings 186 / 0
--bins 1060 / 0
--lib 990 / 0

Build warning-free.

The ownership test asserts the returned pointer is not the caller's own buffer — ownership, not string contents. The emitter tests pin both register contracts on both architectures. The view-protocol example self-tests headlessly:

initial=nothing yet after++=2 items after-=one item reset=nothing yet
PASS: the view tree, the string ABI and PHP-side state all round-trip

examples/ios-device-probe/ — what the simulator did not prove

An iOS Simulator app is a native macOS process loading the iOS frameworks, so elephc's raw syscalls hit the macOS table — exactly the one they were written against. The simulator validates codegen, the C ABI, marshaling, ownership, Mach-O and linking. It does not touch the device sandbox.

This probe measures the difference: it runs compiled PHP inside the app and reports which operations succeed — container file I/O, paths outside the container, getcwd/tmp/env, the clock, DNS. Nothing aborts on failure, so one run measures everything instead of stopping at the first denial. Its own simulator output makes the case:

OK   outside./tmp write         permitted
OK   outside./etc/hosts stat    readable
OK   env.getenv HOME            /Users/…/CoreSimulator/Devices/…/data

run.sh builds, bundles, signs, installs through devicectl and launches with --console. Device mode stops at the signature — only an Apple ID can issue the certificate and profile — with everything up to that point assembled and verified as platform IOS, arm64.

It also surfaces a constraint consumers should not discover the hard way: an elephc static library is not self-contained. Filesystem PHP reaches __rt_fopen_maybe_phar, which pulls in the elephc-phar bridge, which pulls in bzip2. Emit::Staticlib leaves bridges and native packages to the consuming project by design — as a C library leaves its dependencies to its consumer — so an Xcode target also links the bridges it uses, cross-compiled for the same target (rustup target add aarch64-apple-ios), plus their system libraries. Which bridges depends on which PHP surface you use; the view-protocol example needs none.

Verified against a real iOS SDK

Both targets build and link:

device simulator
archive members platform IOS platform IOSSIMULATOR
host executable links links
runs needs provisioning + signed bundle 42 hi iOS 6

./scripts/ios-relink-spike.sh drives it, in either mode.

Not verified

Execution on a physical device. Needs provisioning and a signed app bundle — app packaging rather than a compiler concern. The device archive and executable build and link identically to the simulator's.

x86_64 at run time. Its code paths are asserted at the emitted-assembly level only — macOS cannot assemble the ELF x86_64 runtime. CI covers it.

Specifies emitting an arm64 static library of compiled PHP embeddable in
an Xcode project. Records the architecture decision (iOS as a Darwin
sub-variant on Target rather than a new Platform), the blocking export-ABI
gap (#[Export] cannot return a string), the five work packages, and the
deliberate deferral of the raw-syscall migration.

Refs illegalstudio#662
The 71 literal 'Target { ... }' constructions cited in the first draft were
grep false positives (LockedTarget, StaticPropertyAssignmentTarget, function
return types). There are none: every Target comes from one of three
constructors, only 7 of whose 131 call sites are production code.

Also records what recon surfaced beyond the linker: native_deps rejects an
iOS compiler triple outright, three persisted keys derive from
Target::as_str() and would collide between macOS and iOS builds, and
supports_current_backend() already returns true for (MacOS, AArch64) --
confirming the sub-variant decision over a new Platform.

Refs illegalstudio#662
Documents what a Str actually is (ptr+len with three provenances, move
semantics rather than refcounting) and the two mismatches a blind trampoline
would hit: arm64 returns aggregates in (x0,x1) while the internal pair is
(x1,x2), and elephc_free receives its argument in rdi on x86_64 while
__rt_heap_free_safe reads rax. Both would corrupt silently.

Settles three ABI decisions: persist every exported Str return, translate
NULL_SENTINEL to a real C NULL at the boundary, and state that returned
strings are not NUL-terminated.

Refs illegalstudio#662
…t 4 scope

Three corrections from recon against the real tree:

- A staticlib must NOT reuse the cdylib PIC path. pic_data_refs exists for
  dlopen-time cross-object resolution, not for position independence; the
  non-PIC path is already PC-relative and already yields PIE executables.
  Emit::Staticlib takes Emitter::new, and runtime_pic stays Cdylib-only.
- Lot 1 is wider than a signature-list edit. Persisting in the trampoline
  would leak on the common path, so the persist must happen in lowering --
  which today has no notion of exports and needs the set threaded in.
- Lot 4 lists six builtins, not seven: proc_open does not exist in the
  compiler. The insertion point is the existing per-builtin check: hook,
  which yields exact source spans, not a backend audit which does not.

Also records that the ar flow in archive_dedup is a precedent rather than
reusable logic, and the exhaustive Emit match sites a third variant touches.

Refs illegalstudio#662
Adds PhpType::Str to the v1 cdylib export return set and makes the C ABI
around it real.

Ownership: strings are move-semantics, not refcounted -- __rt_decref_any
sends kind 1 straight to __rt_heap_free_safe, which frees rather than
decrements. An exported string return must therefore be exactly one owned
heap block, so lowering now persists it unconditionally and that persist
*replaces* the borrow acquire instead of stacking on it; Str is
lifetime-tracked, so acquiring and then copying would strand the acquired
reference. Without the forced persist, 'return $param;' would hand the host
back its own pointer and 'return "literal";' a .rodata address, neither of
which elephc_free may legitimately release.

Two register contracts differ per architecture and both would have corrupted
silently:
- AAPCS64 returns a 16-byte aggregate in x0/x1 while elephc produces the
  pair in x1/x2, so the trampoline shifts it down and can no longer
  tail-branch. x86_64 needs no shift -- rax/rdx already is the SysV
  two-INTEGER struct return.
- elephc_free receives its argument in rdi under SysV but
  __rt_heap_free_safe reads rax, so x86_64 bridges the register; AArch64
  passes it in x0 and tail-branches unchanged.

The in-band NULL_SENTINEL is translated to a real C NULL with a zero length
at the boundary, branchlessly, so no host deref lands on a wild address.

Tests: an end-to-end host asserting the returned pointer is not the caller's
own buffer -- ownership, not string contents -- plus emitter tests pinning
both register contracts on both architectures.

Refs illegalstudio#662
Automates the one experiment that answers whether elephc's existing arm64
Mach-O output links and runs against an iOS SDK, with no compiler change:
emit assembly, assemble, relink the user object plus the cached runtime
object against the iOS SDK instead of the macOS one, then build a C host for
the simulator triple and run it through simctl spawn.

The chain minus the SDK swap is already verified: run against the macOS SDK
it returns '42 hi iOS 6', exercising the int export, the string export and
elephc_free. Only -syslibroot and -platform_version remain untested.

Isolates XDG_CACHE_HOME deliberately -- the runtime object's cache key encodes
the program's runtime feature set, so the shared cache holds several
candidates and a newest-match glob would link the wrong one.

Detects Command-Line-Tools-only installs and reports how to fix it rather
than silently falling back to the macOS SDK.

Refs illegalstudio#662
@Guikingone Guikingone self-assigned this Aug 1, 2026
@Guikingone Guikingone changed the title feat(core): iOS support through cdylib feat(platform): iOS support through cdylib Aug 1, 2026
@github-actions github-actions Bot added area:codegen Touches target-aware assembly or backend lowering. area:eir Touches EIR definitions, lowering, validation, or passes. area:tooling-ci Touches CI, development tooling, Docker, or repository scripts. size:m Medium-sized pull request. type:feature Introduces new user-visible behavior or capabilities. labels Aug 1, 2026
Lot 2 of the iOS spec: a native macOS app whose entire interface is decided
by compiled PHP. render_view() returns a serialized view tree, dispatch()
returns the next one after an event, and the Swift host knows four node types
and nothing else -- layout, labels, pluralisation and state all live on the
PHP side.

This is the shape that fits ahead-of-time compilation. A template engine has
to evaluate itself on the device, which needs a PHP runtime there; a tree
generator compiles once and ships as machine code.

Needs only the Command Line Tools: swiftc ships with them and SwiftUI is a
system framework, so no Xcode install and no .xcodeproj are involved.

Two constraints any Swift host will hit, documented in the example:
- ElephcStr must be declared in C. Swift rejects a Swift-declared struct in a
  @convention(c) signature, because only a C type carries the guarantee that
  the value rides the platform's aggregate-return registers.
- @main requires -parse-as-library.

A --selftest mode runs the round trip headlessly so the example is verifiable
without a display: it asserts the tree decodes into a typed model with the
expected shape, that a string crosses in both directions, that elephc_free
runs on every returned buffer, and that PHP-side state survives across host
calls through a function static.

Refs illegalstudio#662
iOS is a variant on Target, not a fourth Platform. Every syscall number,
struct offset, errno, open flag and Mach-O convention is identical between
macOS and iOS on arm64 -- they share XNU -- so a new Platform would grow ~181
match arms across 54 files that all return what macOS already returns.
Target::supports_current_backend() needing no change is the proof: the arm64
Darwin backend serves iOS unchanged.

What genuinely differs is narrow, and is what this wires:
- which SDK xcrun resolves (linker/sdk.rs now always passes --sdk explicitly;
  relying on the default would silently hand back the macOS SDK when the
  requested iOS one is missing);
- which -platform_version ld64 records, with a 13.0 deployment floor for iOS
  rather than macOS's report-the-SDK-version behaviour, which is preserved;
- which compiler triple native dependencies validate against --
  native_deps/toolchain.rs matched a fixed 'darwin' and so rejected every iOS
  toolchain outright.

Target::as_str() encodes the variant because three persisted keys derive from
it: the runtime object cache filename, the native-dependency receipt JSON and
the package catalog. A macOS and an iOS build sharing a string would silently
reuse each other's artifacts; a test pins that they cannot.

A missing iOS SDK no longer suggests 'xcode-select --install' -- the Command
Line Tools it installs ship no iOS SDK, so that advice loops.

Verified: --target ios-arm64 runs the full pipeline and assembles a real
runtime object, cached under its own key alongside macos-aarch64. Only the
link stops, on the absent SDK.

Refs illegalstudio#662
Produces an ar archive of the same exported surface as a cdylib, for a host
that links elephc into its own binary -- an Xcode project, say -- rather than
loading it at run time.

Deliberately NOT the PIC path. Emitter::new_pic exists for dynamic loading,
where the loader resolves cross-object references at dlopen time; its GOT
indirection is unrelated to position independence as such. An archive is
merged once into the host's final binary by the host's own linker, exactly
like the executable path, whose non-PIC output is already PC-relative and
already yields PIE binaries. The new test proves it: the archive links into a
PIE host and runs.

An Emit::is_library() predicate replaces the scattered
matches!(emit, Emit::Cdylib) checks that both library kinds share -- export
trampolines, skipping main, rejecting --web. The one Cdylib-only check stays
Cdylib-only: ELF dynamic-symbol visibility is meaningless for a .a. The two
linker command renderers get explicit unreachable arms rather than a catch-all,
so a future emit kind cannot silently inherit link behaviour.

Archiving does not go through the link plan. Bridge staticlibs and managed
native packages stay separate .a files for the consuming project to link
alongside this one, as a C library leaves its dependencies to its consumer;
rolling them in would duplicate every symbol the host also links directly.
'ar rcs' writes the symbol index in one step, so no ranlib pass is needed, and
a stale archive is removed first because 'r' replaces matching members but
never removes vanished ones.

Refs illegalstudio#662
@github-actions github-actions Bot added area:platform Touches targets, object formats, linking, or platform support. scope:multi-area Touches more compiler areas than the automatic area-label cap. and removed area:tooling-ci Touches CI, development tooling, Docker, or repository scripts. labels Aug 1, 2026
system, passthru, exec, shell_exec, popen and pclose exist as libSystem
symbols on iOS and link happily, then always fail inside a sandbox that
forbids fork. Compiling them is a mistake worth reporting at build time
rather than shipping as a runtime surprise on a device.

The gate lives in each builtin's check: hook rather than in a backend audit,
because a hook has the call's exact span: the diagnostic names the builtin,
names the target and carries file:line:col. A backend-level audit could
guarantee no builtin is forgotten, but the WASM equivalent proves the cost --
its errors can only cite block and instruction indices, having no wiring to
CompileError.

Reaching the variant required the checker to hold the whole Target rather than
just a Platform. check_with_target already received one and dropped the
variant on the floor; the field is now Target, and the single existing
platform-dependent check reads target.platform.

proc_open and its family are deliberately absent: they do not exist in the
compiler at all. The guard's doc comment records that they must adopt it on
the day they are added.

Refs illegalstudio#662
@github-actions github-actions Bot added area:builtins Touches PHP builtin declarations or emitters. area:types Touches type checking, inference, or compatibility. size:l Large pull request. and removed area:eir Touches EIR definitions, lowering, validation, or passes. area:codegen Touches target-aware assembly or backend lowering. size:m Medium-sized pull request. labels Aug 1, 2026
Adds ios-arm64 and ios-sim-arm64 to the shared supported_targets, so any PHP
program reaching PCRE or zlib can build its native dependencies for iOS. Both
are plain autoconf/make projects with no host assumptions beyond a C compiler,
so neither needed a per-package caveat.

Two correctness fixes the entries exposed:

- validate_tuple accepted a simulator compiler for a device target and vice
  versa. Device and simulator triples differ only by a trailing '-simulator',
  so the substring match let incompatible objects through. Each now rejects the
  other. The accepted spellings are measured, not assumed: clang -dumpmachine
  reports arm64-apple-ios13.0 under -target arm64-apple-ios13.0, and
  arm64-apple-darwin25.5.0 with no target flag.
- resolve_toolchain hardcoded '--sdk macosx' when reading the SDK version into
  the toolchain fingerprint, so an iOS build recorded the host SDK and bumping
  the iOS SDK never invalidated the cache.

Device and simulator artifacts do not collide despite sharing an abi string:
ArtifactKey carries target.as_str() as its own path component, which the Apple
variant already distinguishes.

The lockfile test asserted a hardcoded count of three target plans. It now
derives the expectation from the catalog and checks the target names, so
adding a target is not a chore and the assertion says what it means.

Not verified: an actual pcre2 or zlib build for iOS, which needs the SDK. The
catalog entry is a support claim; the first real build may want recipe
adjustments.

Refs illegalstudio#662
Lot 0 finally ran against a real SDK and failed at the link:

  ld: building for 'iOS-simulator', but linking in object file built for 'macOS'

A Mach-O object records the platform it was assembled for. 'as -arch arm64'
stamps macOS and has no way to say otherwise, so relinking against the iOS SDK
was never going to be enough -- the assembly step has to target iOS too. This
invalidates the earlier claim that only -syslibroot and -platform_version
remained untested; it took the SDK to surface, which is exactly what Lot 0 was
for.

Non-macOS Apple targets now assemble through clang with -target and -isysroot,
which stamps the platform. The user object and the cached runtime object built
this command separately and would have drifted, so both now share
linker::assembler_command -- they must agree or the link fails on whichever one
disagrees.

The iOS deployment floor becomes one constant, APPLE_IOS_MIN_OS. The assembler
writes it into each object's LC_BUILD_VERSION and the linker into the image's
-platform_version; a mismatch between them is itself a link error.

Verified end to end: '--target ios-sim-arm64 --emit staticlib' produces an
archive whose members are both platform IOSSIMULATOR, and an iOS Simulator
arm64 executable links against it. Running it needs a simulator runtime, which
does not fit in the remaining disk.

macOS is byte-unchanged: its objects still report platform MACOS and a
statically linked host still runs.

Refs illegalstudio#662
The script hand-assembled with 'as' and relinked against the iOS SDK, on the
premise that only the SDK differed. It does not: a Mach-O object records the
platform it was assembled for, so that route could never have linked. With the
compiler assembling iOS objects correctly there is nothing left to work around,
and the script now runs '--target ios-* --emit staticlib' and reports each
archive member's Mach-O platform.

Both modes verified: device members are platform IOS, simulator members are
IOSSIMULATOR, and a host executable links for each. Execution still needs a
booted simulator.

Refs illegalstudio#662
The spike executed inside a booted iPhone 17 Pro simulator (iOS 26.5, arm64)
and printed '42 hi iOS 6': the int export, the string export and elephc_free
all working through the same C ABI the cdylib path exposes.

That closes the last unverified step of the campaign. The device target builds
and links identically; running it needs provisioning and a signed bundle,
which is app packaging rather than a compiler concern.

Refs illegalstudio#662
The example now builds a real iOS app, installs it on a booted simulator,
launches it and captures a screenshot -- so the result is something you look
at rather than a line in a log. Same view.php, same Swift, both platforms.

Switched from dlopen to static linking to get there. dlopen was fine on macOS
but is the wrong shape for iOS, and a .a is the delivery form an Xcode project
consumes anyway; the exports become ordinary C symbols declared in
elephc_abi.h, which removes the dlsym machinery entirely.

One trap worth the comment it got: swiftc's -sdk covers compilation but not
the link, where it drives clang with the host sysroot -- an iOS build warns
'using sysroot for MacOSX but targeting iPhone' until -Xclang-linker -isysroot
passes the SDK through.

Verified on both: --selftest prints the same round-trip result on macOS and
inside the simulator, and the launched app renders the tree view.php describes
-- title, pluralised label, three buttons, caption.

Refs illegalstudio#662
The iOS catalog entries shipped as a support claim. Exercising it against a
real SDK broke immediately:

  Invalid configuration 'arm64-apple-ios13.0-simulator':
  Kernel 'ios13.0' not known to work with OS 'simulator'.

The recipes passed toolchain.target_tuple -- whatever 'clang -dumpmachine'
prints -- as --host and CHOST. autoconf feeds those through config.sub, which
parses an Apple cross triple as kernel 'ios13.0' plus OS 'simulator' and
rejects it.

toolchain.abi is elephc's normalized identity and is exactly what config.sub
wants. Measured against a real config.sub:

  arm64-apple-ios13.0-simulator  rejected
  aarch64-apple-ios              accepted
  aarch64-apple-darwin           accepted

Linux is unaffected in substance: its abi is the canonical
'<arch>-unknown-linux-<gnu|musl>' form of the same information.

Verified: pcre2 and zlib now build for ios-sim-arm64, and their archive
members report platform IOSSIMULATOR, arch arm64, cached under
ios-sim-arm64/aarch64-apple-ios. The host macOS build still produces MACOS
members.

Note for consumers: ELEPHC_NATIVE_CC_* takes a command path, not a command
line, so an iOS toolchain needs a wrapper script carrying -target and
-isysroot.

Refs illegalstudio#662
The simulator result proves less than it looks: an iOS Simulator app is a
native macOS process loading the iOS frameworks, so elephc's raw syscalls hit
the *macOS* table -- exactly the one they were written against. Codegen, the C
ABI, marshaling, ownership and linking are all validated by it; the device
sandbox is not touched at all.

This probe measures the difference. It runs compiled PHP inside the app and
reports which operations succeeded: container file I/O, paths outside the
container, getcwd/tmp/env, the clock, DNS. Nothing aborts on failure, so one
run measures everything rather than stopping at the first denial.

The simulator report already makes the case -- it happily writes to /tmp,
reads /etc/hosts, and reports a HOME under CoreSimulator. Run both modes and
diff.

run.sh builds, bundles, signs, installs through devicectl and launches with
--console. Device mode stops at the signature with instructions, because only
an Apple ID can issue the certificate and profile; everything up to that point
is assembled, verified as platform IOS arm64.

Surfaces a constraint no consumer should discover the hard way: an elephc
static library is not self-contained. Filesystem PHP reaches
__rt_fopen_maybe_phar and so needs the elephc-phar bridge cross-compiled for
the same target, plus -lbz2 -lz. Emit::Staticlib leaves those to the consuming
project by design, and the README says which and why.

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

Labels

area:builtins Touches PHP builtin declarations or emitters. area:platform Touches targets, object formats, linking, or platform support. area:types Touches type checking, inference, or compatibility. scope:multi-area Touches more compiler areas than the automatic area-label cap. size:l Large pull request. type:feature Introduces new user-visible behavior or capabilities.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

iOS target: emit a static library of compiled PHP embeddable in an Xcode project

1 participant