feat(platform): iOS support through cdylib - #663
Draft
Guikingone wants to merge 19 commits into
Draft
Conversation
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
cdylibcdylib
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
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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #662.
Makes iOS a real compilation target. Compiled PHP runs on the iOS Simulator:
iPhone 17 Pro, iOS 26.5, arm64.
spike_add(40, 2)returned 42,spike_greet("iOS", 3)returned"hi iOS"with length 6, andelephc_freereleased 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 fourthPlatform.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
Platformwould 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 atpipeline.rsnever had to open.What genuinely differs turned out to be four things:
linker/sdk.rsnow always passes--sdkexplicitly. Relying onxcrun's default resolves whatever the selected developer directory offers, silently handing back the macOS SDK when the requested iOS one is missing-platform_version13.0deployment floor; macOS keeps its report-the-SDK-version behaviour, untouchednative_deps/toolchain.rsmatched a fixeddarwinand therefore rejected every iOS toolchain outrightforkand cannot work — see belowTarget::as_str()encodes the variant because three persisted keys derive from it: the runtime object cache filename, the native-dependencyArtifactReceiptJSON, 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 onis_v1_return_typeacceptedInt | Float | Bool | Voidand noStr, 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_anysends kind1straight 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 —Stris 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 andreturn "literal";a.rodataaddress, neither of whichelephc_freemay legitimately release.Two register contracts, both silent failure modes:
x0/x1while elephc produces the pair inx1/x2. The trampoline shifts it down and can no longer tail-branch. x86_64 needs no shift —rax/rdxalready is the SysV two-INTEGER struct return.elephc_freereceives its argument inrdiunder SysV, but__rt_heap_free_safereadsrax. x86_64 bridges the register; AArch64 passes it inx0and 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_SENTINELis translated to a real CNULLwith a zero length at the boundary, branchlessly.--emit staticliblib<stem>.awith the user object, the runtime object and a symbol index. A C host links it directly — nodlopen— and runs. This is the delivery form an Xcode project consumes; a.aalso avoids the embedded-framework signing dance a.dylibin an app bundle requires.It is deliberately not the PIC path.
Emitter::new_picexists for dynamic loading, where the loader resolves cross-object references atdlopentime; 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 scatteredmatches!(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 explicitunreachable!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.afiles for the consuming project, as a C library leaves its dependencies to its consumer.Capability gating
system,passthru,exec,shell_exec,popenandpcloseare refused at compile time for iOS targets: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 toCompileError/Span.proc_openand 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:
A Mach-O object records the platform it was assembled for.
as -arch arm64stamps 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-syslibrootand-platform_versionremained untested was wrong.Non-macOS Apple targets now assemble through
clangwith-targetand-isysroot. The user object and the cached runtime object built that command in two separate places and would have drifted; they now share onelinker::assembler_command, because they must agree or the link fails on whichever one disagrees.APPLE_IOS_MIN_OSis a single constant for the same reason: the assembler writes it into each object'sLC_BUILD_VERSIONand 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 MACOSand a statically linked host still runs.Native dependencies
pcre2andzlibare catalogued forios-arm64andios-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_toolchainalready refuses any cross target without explicitELEPHC_NATIVE_CC/AR/RANLIB; for iOS those must carry-targetand-isysrootor configure silently probes the host.Adding the entries exposed two correctness bugs:
validate_tupleaccepted 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 -dumpmachinereportsarm64-apple-ios13.0under-target arm64-apple-ios13.0, andarm64-apple-darwin25.5.0with no target flag.resolve_toolchainhardcoded--sdk macosxwhen 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
abistring:ArtifactKeycarriestarget.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— whateverclang -dumpmachineprints — as--host/CHOST. autoconf feeds those throughconfig.sub, which parses an Apple cross triple as kernelios13.0plus OSsimulatorand rejects it outright:toolchain.abiis elephc's normalized identity and is exactly whatconfig.subwants. Measured against a real one:arm64-apple-ios13.0-simulatorrejected,aarch64-apple-iosandaarch64-apple-darwinaccepted. Linux is unaffected in substance — itsabiis the canonical<arch>-unknown-linux-<gnu|musl>form of the same information.pcre2 and zlib now build for
ios-sim-arm64, with archive members reportingplatform IOSSIMULATOR, arch arm64, cached underios-sim-arm64/aarch64-apple-ios. The host macOS build still producesMACOSmembers.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-targetand-isysroot.scripts/ios-relink-spike.shAnswers 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 andelephc_free. Only-syslibrootand-platform_versionremain untested. It isolatesXDG_CACHE_HOMEdeliberately — 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.phpand the same Swift.run-ios.shbuilds a real iOS app, installs it on a booted simulator, launches it and screenshots the result — no.xcodeprojinvolved,swiftcand 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,dlopenbeing 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:
ElephcStrmust 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),@mainrequires-parse-as-library, and-sdkdoes not reach the link step —swiftcdrivesclangto link with the host sysroot unless-Xclang-linker -isysrootpasses 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.cdylib_testsnative_depserror_testscodegen::oopir_backend_smoke_testcodegen::strings--bins--libBuild 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:
examples/ios-device-probe/— what the simulator did not proveAn 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:run.shbuilds, bundles, signs, installs throughdevicectland 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 asplatform 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 theelephc-pharbridge, which pulls in bzip2.Emit::Staticlibleaves 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:
platform IOSplatform IOSSIMULATOR42 hi iOS 6./scripts/ios-relink-spike.shdrives 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.