Inject into iOS Simulator processes (Evolution 0013) - #106
Open
Mx-Iris wants to merge 28 commits into
Open
Conversation
Injecting into a simulator process currently crashes the target. The injector resolves `pthread_create_from_mach_thread`, `dlopen` and `sandbox_extension_consume` against its own address space via `dlsym(RTLD_DEFAULT, ...)`, then hands those absolute addresses to the target. That holds only while injector and target share a dyld shared cache. A simulator process runs its own cache — mapped at the same 0x180000000 base but laid out differently — so the address lands on unrelated code. Three SpringBoard crashes pin this down at register level: `lr` sits exactly 4 bytes past the host's `pthread_create_from_mach_thread`, and `x0`/`x1`/`x2` match what `loader_arm64.s` sets up for that call. The `task_for_pid` failure the user saw is a second-order effect — the target was already dead. Feasibility is verified: an `iphonesimulator` payload dlopen'd into `gamecontrollerd` loads, starts its Swift runtime, advertises over Bonjour and gets a connection back from the host. Bonjour needs no changes — the payload already takes that branch at compile time on non-macOS platforms. One blocker surfaced during verification: the iOS Bonjour identity is device-level (service name and identifier both derive from the device), a leftover from "one app per device". With multiple injected processes the host skips every endpoint after the first as a duplicate, so only one ever connects. Resolved by giving devices a Section and processes the entries under it, reusing the grouping `rebuildSections()` already performs for Mac. Numbered 0013: 0007 and 0012 are each already taken by two unmerged branches, and 0012 is the highest number in use across all of them. Status: Accepted.
The file was already named 0013 but its title and the index row still carried 0007, which is taken by an unrelated proposal. Numbering is how proposals cross-reference each other, so a stale number in two places points readers at the wrong document.
An iOS peer advertised one Bonjour identity per *device*: the service name was the device name and the engine identifier was the MobileGestalt UDID. That held while a device ran a single app embedding the server, and breaks the moment injection puts a payload in several processes at once — which is exactly what injecting an iOS Simulator does. The visible symptom was engines showing up under the device's name, but the real damage was in the host: `connectToBonjourEndpoint` deduplicated by service name, so the second payload-carrying process on a device was written off as a duplicate of the first and never connected at all (measured: a second injected daemon sat in LISTEN indefinitely). Split the two identities that were conflated: - The Bonjour service name becomes process-level (`<deviceID>-<pid>`); it only has to be unique, and is no longer shown to anyone. - What the user reads travels in the TXT record: `rv-device-id` groups a device's engines into one section, `rv-proc-name` titles the entry, and `rv-proc-pid` completes the process-level key. The host now keys dedup, `pendingReconnectEndpoints` and teardown by `RuntimeNetworkEndpoint.uniqueKey`, takes `hostID` from the device and the entry title from the process. That reproduces the shape local Mac injection already has: one section per device, one entry per process. Two consequences worth calling out: - `RuntimeRemoteEngineDescriptor` gains `hostID`. The mirroring path derived it from `originChain.first`, which is an *instance* ID; that coincided with the host only while a device ran one installation. Left alone, a mirrored engine and a direct route to the same device would land in separate sections and `deduplicateForwardedMirrors` would stop suppressing the duplicate. - `RuntimeSource.identifier` keys Bonjour clients by identifier instead of name, because the name is now a process display name that two processes can share — and that string keys notifications and the mirror registry. `localInstanceID` no longer persists when running inside an injected process: writing `RuntimeViewer.localInstanceID` into SpringBoard's preferences is a side effect injection has no business causing. Peers predating the TXT keys decode them as nil and fall back to the service name throughout, so their behaviour is unchanged and no compatibility branch is needed. Implements landing step 2 of Evolution 0013. Adjudication of the three regressions this rework introduced (and the one deliberately left alone) is recorded in Documentations/KnownIssues.
`@DependencyEntry` requires the entry's value type to be `Sendable`, which these two never were; a newer swift-dependencies enforces it through an `IsolationCheck` macro and both stopped compiling. `AppDefaults` gets `@unchecked` rather than a real conformance because the safety lives where the compiler cannot see it: the class holds no mutable state of its own, `@UserDefault` goes through `UserDefaults`, and `@FileStorage` serialises every access through a concurrent queue with barriers. Rx pipelines already read it from background schedulers, so this states an existing fact rather than granting new access — the same argument `RuntimeHelperClient` and `RuntimeInjectClient` already make. `appRouter` needs `& Sendable` spelled out: `Router` is `@MainActor`, but an existential does not inherit `Sendable` from that, so the protocol's own isolation is not enough to satisfy the check. `AppCoordinator` is `@unchecked` because `Coordinator` descends from `NSResponder`; every `Router` member is `@MainActor` and the sole instance is built under `MainActor.assumeIsolated`, which the compiler cannot derive through the AppKit base class. Verified that widening the existential does not break the four call sites (`trigger` and `rx.trigger` both resolve through protocol extensions).
Measuring the two things landing step 5 depends on turned up one confirmation and one error in the proposal. Confirmed: `TASK_DYLD_INFO` on a simulator process returns the image list `dyld_sim` maintains, not the host dyld's — 576 of 580 images belong to the simulator's RuntimeRoot, and the three host images are exactly libsystem_platform / libsystem_kernel / libsystem_pthread. The fallback the proposal held in reserve (locating dyld_sim's own table through its __DATA segment) is not needed. Wrong: the proposal resolved `pthread_create_from_mach_thread` by taking the offset from the *injector's* copy and adding the target's load address. The arm64 and arm64e slices of libsystem_pthread.dylib do not agree on that offset (0x7d84 vs 0x847c). The injector runs against the host cache's arm64e copy while the simulator process maps the arm64 one, so the computed address lands 0x6f8 bytes into the function — still a crash, and one whose symptoms are indistinguishable from resolving the address entirely wrong. Resolve against the target's own LC_SYMTAB instead: it depends on no host state, assumes nothing about whether the target mapped the cache or the file, and reuses the Mach-O parsing the other two symbols already need. Also refreshed landing step 4, whose premise expired: the MachInjector local path now exists and the whole chain resolves locally under USING_LOCAL_DEPENDENCIES=1. What remains is that neither build script passes that variable, so an on-device daemon still consumes the remote pin.
Injection delivered one payload to every target: the macOS slice at /Library/Frameworks/RuntimeViewerServer.framework. A macOS process and an iOS Simulator process on the same Mac share a cputype and differ only in LC_BUILD_VERSION's platform, so nothing about the target's architecture reveals the mismatch. dyld refuses the wrong slice at load time, and the daemon's remap fallback then projects the payload in anyway — which the kernel kills on page-in. That is how injecting a simulator process killed it. Two new types, deliberately separate: InjectionTargetPlatformProbe reads what a target *is*, from its executable's LC_BUILD_VERSION. Not from the executable path: the RuntimeRoot prefix identifies the daemons a simulator boots, but an app the user built runs from the device's data container with no such marker. PayloadPlatform describes what we can *give* it. The gap between the two is where a refusal belongs — a tvOS Simulator process is a perfectly readable target with no slice to offer, and it now gets an error instead of the nearest slice and dyld's verdict. This covers the host half of the platform guard the proposal lists as its own step. The simulator slice ships as a sibling bundle, RuntimeViewerServer-iphonesimulator.framework. The two cannot be merged: same architecture, different platform, which is exactly what a fat binary cannot express and an .xcframework splits into separate files. It is embedded by a build phase rather than copied into the built .app, because everything under Resources is sealed by the app's code signature and adding a file afterwards invalidates it — which in turn breaks the SMAppService daemon registration. It cannot be a target dependency either: Xcode rejects iOS-family embedded content from a macOS app target, the same constraint that keeps the Catalyst helper out, so RunScript.sh and ArchiveScript.sh own the ordering and pass the payload path explicitly (BUILD_DIR is not reliable under `xcodebuild archive`). Verified end to end: the built slice is platform 7 / arm64 / ad-hoc signed with both entry symbols present; dlopen'd into a live iOS 18.5 simulator daemon it returns a non-NULL handle, NULL dlerror, and resolves its entry point, with the simulator's own log carrying the full startup sequence. The app it is embedded in still passes codesign --verify. Doing so also settled two questions the proposal had left open, both recorded there: dyld_sim does not hijack /Library/Frameworks (it tries the RuntimeRoot path first and the host path second, for every prefix), and SIMULATOR_UDID really is present in each simulator process.
…targets
The attach flow launches a client engine before injecting and then confirms
the payload connected back to it. A simulator payload never will: it picks
its transport at compile time and always advertises over Bonjour, so the
XPC/socket endpoint prepared for it is never dialled and the attach times
out with a message about sandboxes that has nothing to do with the failure.
Nothing new is needed to *connect* — measured on a live iOS 18.5 simulator,
the payload advertised and the browser already running in the host app
connected to it unprompted. What was needed is to stop preparing the
channel that goes unused, so the flow splits by payload platform:
attachToLocalProcess keeps the Mac sequence unchanged, and now tears down
the engine it launched on any failure downstream (previously the caller's
catch did this for both paths).
attachToSimulatorProcess injects and then waits for the advertisement.
No engine is launched, so none has to be torn down. The sandbox probe is
skipped too — it exists to choose between the XPC and localhost-socket
transports, and neither is in play.
awaitInjectedBonjourEngine matches on pid rather than service name: a
simulator process is a real host process, so the pid its payload publishes
is the one that was injected. The endpoint key is {deviceID}-{pid} and the
device half is a UUID with dashes of its own, so the pid is read as the
last dash-separated component rather than by suffix match.
Its timeout error names the one thing that cost real time to discover
while debugging this: a simulator payload's startup log goes to the
simulator's own logd, so `xcrun simctl spawn <udid> log show` shows it and
the host's `log show` does not.
Injecting a simulator process through the product path — Attach to Process, the daemon, MIMachInjector's dlopen — now works: SpringBoard on an iOS 18.5 simulator takes the payload, all three startup lines land, and the process stays alive. That closes the loop the proposal opened with, where the same process was killed three times. It also settles a planned change by making it unnecessary. The proposal called for splitting thread state construction by cpusubtype, writing a bare PC for arm64 targets instead of a PAC-signed one. That code was never touched and the injection succeeded anyway. Why is not established, so the split is downgraded to an optional optimization and the section is kept as the first place to look if shellcode ever jumps wild on an arm64 target — rewriting a verified thread state path on reasoning alone is the larger risk.
Injecting backboardd and reading its types confirms the browsing half of the end-to-end step. Recorded alongside it: SpringBoard is the wrong process to judge this by. Its main binary is 249 KB and carries no __objc_classlist section at all — the implementation lives in SpringBoardHome, SpringBoardUI and friends — so an empty view there is the correct result, not a failure. It is also the most obvious process to reach for, which makes it a ready source of a false failure signal. backboardd (2.5 MB, __objc_classlist 0x558 bytes, 171 classes) is the honest comparison.
Building against a local dependency checkout required exporting USING_LOCAL_DEPENDENCIES=1 by hand. Forgetting it does not fail — the build silently resolves the remote pin instead, and the symptom is "I changed MachInjector and nothing happened," which gives no hint that the build ate the wrong dependency. Off by default. ArchiveScript.sh's comment says outright that a release should not use it: main has to compile against the released pins. It is there for validating a release build locally before the dependency ships.
The root cause of injecting a simulator process killing it is one sentence — the injector's address space is not the target's — but it has three independent variants, each of which fails differently: a host symbol address fed to the target, a payload slice built for the wrong platform, and a session established over a channel the payload never dials. Written to ResolvedIssues/ rather than a new Internal/ directory: the project's own index defines that directory as "notes on diagnosed and fixed hard problems, with root cause and verification," which is exactly what this is. Starting a parallel tree would only dilute it. The content is limited to what the code cannot show. Most of its value is in the diagnostic traps: a dlerror truncated at 256 bytes reads like a complete answer and sent this investigation down a wrong path for a while; a simulator payload's os_log never reaches the host's log store, so "there are no logs" looks like "the payload never ran"; and SpringBoard, the most obvious process to test with, has no __objc_classlist section at all, so a successful injection there looks like a failure. No glossary: dyld_sim, RuntimeRoot and SimRuntime are Apple's terms rather than this project's coinages, they are explained in place, and the project has no Glossary.md to add them to.
Two defences landed in swift-helper-service: a payload whose platform does not match its target is refused before injection, and the remap fallback is not attempted for a target running on another platform. The guard compares the payload's platform against the target's, not against the host's as the proposal originally wrote. Writing it that way would have blocked injecting a simulator process at all, which is the thing this proposal exists to support. It also leaves the values as raw LC_BUILD_VERSION numbers: every decision there is a comparison, which stays correct for a platform that does not exist yet, and naming them would duplicate in a second process the judgement the host already makes when it picks a slice. An unreadable platform is not treated as a mismatch. A guard that blocks whenever it cannot see clearly turns every unparseable target into a failed injection, and this is a second line of defence rather than the decision itself.
Mx-Iris
force-pushed
the
feature/inject-ios-simulator-process
branch
from
August 23, 2026 14:29
65686dc to
69d8131
Compare
`Int(_: UInt64)` is a trapping conversion, so a fat-64 slice offset with the high bit set killed the app outright rather than answering "cannot tell" — which is what the type documents it must do for a truncated or hostile Mach-O. A second trap sat one frame deeper: the bounds check computed `byteOffset + size`, which overflows for any offset within four bytes of `Int.max`. Two other answers were wrong rather than fatal. `PLATFORM_VISIONOS` is 11 and the simulator is 12, so a visionOS *device* build was reported as the simulator (and the test asserted it). And the slice-count cap answered `nil`, which the caller cannot tell apart from an unreadable file and turns into a refusal to attach — it now clamps the iteration instead of inventing that refusal. The fat-64 path had no fixture at all, so the crash was unreachable from the suite; `MachOFixture.fat()` only ever built 32-bit headers. The real-simruntime test returned early when no runtime was installed, which swift-testing reports as *passed*. It is a proper skip now, and it also looks in the pre-Volumes `Profiles/Runtimes` location, which a machine can have instead of — or as well as — the newer one.
`clearAllWithHostID` matches one `engineID` namespace, and a peer now has two. `buildEngineDescriptors` stamps whatever that peer held in `hostInfo.hostID`: device-level for a Bonjour route it opened, but instance-level for its own engines, because `RuntimeEngine.init` still defaults `hostInfo.hostID` to `localInstanceID`. The two used to coincide — one installation per device — and stopped when Bonjour identity moved to device level. So in A -> B -> C, where A is a Mac that shares engines, C holds A's mirrors namespaced under A's *instance* ID while its direct route to A is keyed by A's *device* ID. When that route drops, C is told the device ID, matches nothing, and A's mirrors outlive A. `stillReachable` misses them for the same reason and fires a "disconnected" notification while the peer is still on screen — the exact contradiction its comment exists to prevent. `clearAllForDisconnectedPeer` takes both identities. Not by changing the default `hostInfo.hostID`, which would reopen SIMID.4: matching on an instance-level prefix is what `next` already did, and instance IDs carry no device-level meaning, so a single process going away cannot sweep a whole device's mirrors.
The wait after injecting read the endpoint key's trailing dash-separated
component and compared it to the pid, discarding the device half of
`{deviceID}-{pid}`. pid namespaces are per device, so an iPhone on the
same network — or a simulator on somebody else's Mac — advertising a
process with that pid satisfied the wait on its very first poll, before
the payload could have advertised anything. The sheet closed and the
user was told the attach had worked.
A simulator process is an ordinary host process, so nothing about its pid
says which simulator it belongs to; CoreSimulator puts the answer in the
target's environment. `ProcessEnvironmentProbe` reads it via
`KERN_PROCARGS2` — no root, no entitlement, same-user processes only,
which every simulator process is. It honours argc rather than scanning
for the first `KEY=VALUE`, because an argument may well contain `=`.
Failing to identify the device now refuses the attach instead of falling
back to the pid: a false negative here costs an error message, a false
positive costs the user's trust in the one signal that injection worked.
Also in this file: the poll's `try? await Task.sleep` swallowed
cancellation, which would have turned the wait into a main-actor spin for
the rest of the 30s timeout. Nothing cancels it today — the only caller
drops the task handle — but the function is public. And the attach flow
returned silently when the payload URL could not be resolved, leaving the
sheet open with nothing reported at all; every other failure in that
block already throws.
The instance name became `{deviceID}-{pid}` so that several processes on
one device could each be found. Uniqueness is real, but it is mDNS's
requirement, not the host's: a current host keys on the TXT record's
device ID and pid (`RuntimeNetworkEndpoint.uniqueKey`) and ignores the
name entirely.
A host predating those TXT keys does not. It reads `endpoint.name`
straight into the engine title, the window title, and — the part that
lasts — the sidebar's `NSOutlineView` autosave names. So every relaunch
of a peer handed such a host a fresh set of autosave keys that nothing
ever cleans up, and showed a raw UUID where a device name belonged. The
iOS app has external users, so mixed versions are the normal state.
`{host name} ({process name})` restores both. Two payloads inside one
simulator still separate cleanly — their process names differ — and the
case that does collide, two same-named processes on one device, is
resolved by mDNS suffixing the instance name, which a current host never
looks at.
`RuntimeViewerServer` also carried its own copy of the display-name
fallback chain, without this one's `isEmpty` checks. In an injected
payload `Bundle.main` is the *host* app's bundle, and apps that declare
an empty `CFBundleDisplayName` are not hypothetical — three are installed
on a typical Mac — so the XPC and localSocket sources were named the
empty string while the Bonjour branch got it right. The copy is gone and
the chain is now testable on its own.
Also corrects `isRunningInsideInjectedProcess`'s claim that
`localInstanceID` is the only trace an injected payload can leave:
`localDeviceID` reaches a keychain write too, currently held shut by two
unrelated gates rather than by this flag.
Fourteen findings from a `/code-review xhigh` pass, each cross-reviewed by a second session before being ruled on, so the record carries both what was refuted and what was missed the first time. `PR106.13` is a false positive both passes had called real: `return nil` versus `continue` on an unreadable fat entry is provably unobservable, because the entries are contiguous and fixed-size, so once one is out of bounds every later one is too. `PR106.14` (the UDID in mDNS and in some forty `.public` log sites) is deferred — it needs one privacy convention rather than patches, and the salted-hash replacement has to use a *fixed* salt or it re-splits one simulator into a section per injected process. `PR106.1` (Bonjour bookmarks keyed by a pid-bearing identifier, which undoes 917002c's deliberately stable identity) goes to its own PR, bundled with the pre-existing `@FileStorage` defect that lets a renamed peer silently overwrite another peer's bookmarks on decode. Also recorded: four `RuntimeViewerCore` tests that fail on an untouched 69d8131, and a release path that can ship without the simulator payload behind a single `warning:` line. 0013's decision log gains the service-name reversal and why the uniqueness argument that motivated the original shape only held for mDNS.
The adjudication file says what was decided about each finding. This says how the session got there, which matters mostly for the parts that went wrong on the way. Four judgements were reversed by evidence, and all four are worth keeping because each was stated confidently first. The fat-entry loop finding that the original review, my relay of it, and the cross-review all called real is provably unobservable. My "second simulator with the same pid" repro was impossible — simulator processes share the Mac's pid namespace. My claim that `Bundle(url:)` returns nil for a flat iOS framework was refuted by testing it. And my conclusion that the branch could not build was wrong in a way that nearly sent someone to pull unrelated repos: it needs USING_LOCAL_DEPENDENCIES=1, and the pin conflict predates the PR. Also records why the privacy finding grew from two log sites to roughly forty — the text was unchanged and the value changed underneath, so reading only added lines misses it — and the fixed-salt constraint that a naive salted-hash fix would violate.
`localServiceName` composed the advertised instance name from `localHostName`, which that property's own documentation rules out for names other devices see: on an iOS device it falls back to `UIDevice.current.name`, a model name without the `user-assigned-device-name` entitlement. This had reverted what db8388d deliberately fixed. A host predating the TXT keys reads `endpoint.name` straight into its window title and its `NSOutlineView` autosave keys, so the downgrade outlives the session. The comment justifying it -- "resolving it here as well would just repeat the lookup" -- contradicts `resolvedHostName()`'s own note that mDNSResponder's cache makes the second call essentially free, and `makeService` already awaits it on the same code path. Adds `resolvedServiceName()` and switches the two call sites that can degrade: the iOS app's own advertisement and the injected payload's Bonjour branch. `localServiceName` stays for the macOS host, where `SCDynamicStoreCopyComputerName` never degrades, and for the tests. Also records why `serviceName` is deliberately unclamped: a name past the 63-byte DNS-SD limit is truncated on a UTF-8 character boundary by mDNSResponder and registers anyway -- measured through both `dns-sd` and an `NWListener` probe, five cases each, all reaching `.ready`. The TXT record is untouched, so peer matching never sees it. Adjudicated as PR106X.3 (fixed) and PR106X.4 (false positive).
Both scripts logged a `warning:` when the iOS Simulator payload failed to
build and then passed `SIMULATOR_PAYLOAD_PATH` to the app build anyway.
DerivedData is a stable, reused path and a failed compile does not clear
the last successful product, so the embed phase found the previous
framework, `ditto`'d it into the signed bundle and printed "Embedded iOS
Simulator payload" -- contradicting the warning two lines above it.
In a Debug run that means a payload change silently does not take effect;
under `ArchiveScript.sh --upload-to-github` it means shipping a release
whose payload is one build behind, with a single warning line as the only
signal.
Clearing the variable is not enough: the embed phase falls back to
`${BUILD_DIR}/${CONFIGURATION}-iphonesimulator/RuntimeViewerServer.framework`
when it is empty, which resolves to the same stale directory. The failure
branch removes the product instead. `RunScript.sh` needed the assignment
hoisted above the `if` to do so.
Adjudicated as PR106X.5. The release gate itself stays with PR106.16.
005169c inserted `clearAllForDisconnectedPeer` between `clearAllWithHostID`'s doc block and the function it documented, with no blank line between them. Both `///` runs then lexed as one comment and attached to the new function, leaving `clearAllWithHostID` -- still public, still called twice by that new function -- with no documentation at all. Quick Help showed the new function documented as doing both jobs. Also fixes a DocC reference left behind by 610e9d9, which added the `deviceID:` label to `awaitInjectedBonjourEngine` without updating the poll interval's doc comment above it. Adjudicated as PR106X.8 and PR106X.9.
…ope proposal The `/code-review xhigh` second pass on this branch reported 10 findings. One was excluded by the user; the remaining 9 went to two mutually unaware sessions for verification. They agreed on whether each holds, disagreed on two severities, and each caught something the other missed. Adds `KnownIssues/2026-08-27-pr106-cross-review-findings.md` (`PR106X.<N>`) with all nine adjudicated: 5 fixed in this batch, PR106X.4 a measured false positive, PR106X.2 no-fix (the obvious repair would kill the several injected processes on one device that Evolution 0013 exists for), PR106X.6 keeping only its factual half with the notarization claim marked unproven, PR106X.1 deferred, and PR106X.10 new and still unmeasured -- `NWBrowser`'s `.changed` case is dropped entirely, which matters now that the service name survives a peer relaunch. The file also records a methodology result worth keeping: one session's proposed fix for PR106X.1 -- switching the sidebar autosave keys to `source.identifier` -- was overturned by the other, because that identifier carries the pid and the fix would have reproduced the very accumulation PR106.10 had just removed. Reviewing the *fix* matters as much as reviewing the finding. Adds `Evolutions/draft-runtime-bookmark-scope.md`, which takes PR106.1 and PR106X.1 together: one stable `RuntimeBookmarkScope` for the bookmark keys, the sidebar autosave keys, and the on-disk representation, plus the pre-existing `@FileStorage` defect where the encoded key carries `name` while equality ignores it, so one key silently overwrites another on decode. Identity travels in a `@Default` field on the engine descriptor rather than in `RuntimeSource`, whose encoded shape crosses the wire between peers. Drafted from five rounds of clarifying questions; the decision log records what each round settled and that round five overturned round four on a fact found afterwards. Registers `TaskReports/` in the documentation index, which the index's own second line has required since the directory was created in 9e6ca6a, and replaces a stale count of the KnownIssues files. Adjudicated as PR106X.7 (index) and PR106X.1 (proposal).
2c5a648 only cleared the *source* in DerivedData. The embed phase, which runs on every build, still warned and exited without touching the copy it had already put inside the app bundle. `TARGET_BUILD_DIR` survives incremental builds, so the end state was unchanged from before that commit: build 1 embeds the payload, build 2 fails to compile it, the script removes the source, the phase warns "not found" -- and the app still carries and injects build 1's payload. Exactly the bug 2c5a648's message describes. Clearing `PAYLOAD_DESTINATION` in the same branch also covers a plain Xcode build, which runs neither script and which the script-side fix could never reach. Found by cross-session review of 2c5a648. Verified by extracting the phase and running `bash -n`, and by `plutil -lint` plus `xcodebuild -list` on the project; not exercised by a full build.
A read-only review of the draft overturned its central argument and found four more gaps. Per the "a proposal is a decision snapshot" convention the refuted text stays in place, annotated; the corrections are recorded in a new decision-log section rather than by rewriting history. The overturned argument: the draft rejected "add structured fields to `RuntimeSource.bonjour`" on the grounds that `RuntimeSource` crosses the wire inside `RuntimeRemoteEngineDescriptor`, so added associated values would break mixed-version peers. The wire fact holds; the inference does not. The reviewer ran the experiment -- a synthesized-`Codable` enum with two Optional associated values, decoded both directions -- and both succeed: a missing key yields nil, an extra key is ignored. The draft even had the risk direction backwards. The conclusion survives on a harder reason: `RuntimeSource` is also the bookmark dictionary key with a hand-written `==`, so a new field either sits outside equality and solves nothing, or joins it and makes every key on disk unequal to its runtime counterpart -- another full bookmark wipe, the very accident being fixed. Also: replaces the wrong claim that putting `role` in the scope removes the need for a merge rule (pid-accumulated dead keys all collapse onto one scope, so merging is the common case, and a union-with-dedup rule is now specified); pins down the on-disk string grammar, which needed a last-segment-takes-the-rest rule because `.directTCP` hosts can be IPv6 literals and because the reserved v1-to-v2 migration has to parse the string back; splits the ambiguous legacy fallback per consumer, since one reading of it was the pid-bearing fix the proposal itself argues against; and records a known gap it does not close -- mirrored non-Bonjour engines from two hosts collide on one constant identifier. Closes the `917002cc` quotation dispute: `git show` confirms the quote is verbatim. It should never have been filed as needing verification -- one command settles it. Corrects the adjudication entry for the incomplete build fix, and a doc example that wrote a device name in its user-facing form where the reverse lookup yields the mDNS one.
`browseResultsChangedHandler` only acted on `.added` and `.removed`; every other case fell into `default: break`. That was harmless while the service name carried the pid, because a relaunched peer then arrived under a new name and so as a removal followed by an addition. 1df0c1c made the advertised name launch-stable on purpose and moved the pid into the TXT record. A relaunch therefore keeps its service instance name and changes only its metadata, which `NWBrowser` reports as `.changed` — the one case being thrown away. The host stays pointed at a dead pid and never sees the live one. The decision keys on `uniqueKey`, not on the flags: `.metadataChanged` is set for any TXT edit, including ones that leave the process identity alone. It lives in `RuntimeNetworkEndpoint.metadataChange(from:to:)` rather than inside the closure so it can be tested; four cases cover a relaunch, a metadata-only edit, a peer that starts publishing the TXT keys, and one that never does. Mutation-checked: rewriting the rule to compare `name` -- which is exactly the property that survives a relaunch -- turns the suite red. Scope, traced through to the manager rather than assumed: `onRemoved` is a deliberate no-op there, because a listener cancelled after accepting a connection de-registers the service and acting on the removal would drop a peer that is merely flapping. So this restores the host's ability to reach the new process; the row for the dead one still ages out with the heartbeat, which is the behaviour already adjudicated as no-fix in PR106X.2. Whether `.changed` fires at all in a real relaunch is still unmeasured -- it depends on how mDNSResponder presents a goodbye plus re-registration. If it never fires the branch is inert; if it does, it fixes a peer the host could otherwise never reach again. Both review passes called it worth adding either way. Adjudicated as PR106X.10.
`RuntimeViewerServer` and `RuntimeViewerMobileServer` both resolved `PRODUCT_BUNDLE_IDENTIFIER` from `RUNTIME_VIEWER_SERVER_BUNDLE_IDENTIFIER`. That was unremarkable while the two never met; the embed phase added in this branch puts both inside one signed app -- and `PayloadPlatform.installedFrameworkURL` puts both into /Library/Frameworks -- so the app now ships two nested bundles claiming one identity. Splits out `RUNTIME_VIEWER_MOBILE_SERVER_BUNDLE_IDENTIFIER` and points the mobile target's four configurations at it. The macOS target keeps the old value, so nothing already installed changes identity. Safe because nothing reads it: the only occurrence of the literal anywhere in the tree is the xcconfig assignment itself. Payload lookup goes through `Bundle.main.url(forResource:withExtension:)` and `Bundle(url:)` -- by file name and by URL -- and `Bundle(identifier:)` appears zero times in the repo. Verified on the built artefact, not just the settings: the embedded RuntimeViewerServer.framework reports com.MxIris.RuntimeViewerServer and RuntimeViewerServer-iphonesimulator.framework reports com.MxIris.RuntimeViewerMobileServer. Note what this is not: the claim that duplicate nested identifiers are a known notarisation rejection remains unproven -- that hard rule belongs to App Store Connect validation, and this project ships through notarytool and Sparkle. This lands as hygiene, and a full notarisation run is still the outstanding way to settle the question. Adjudicated as PR106X.6.
`ArchiveScript.sh` logged a warning and carried on when the simulator payload failed to build, so `--upload-to-github` could put out a release with no payload at all. Nothing stopped the publisher; the first sign was a user's injection failing on their own machine. Adds a `PUBLISHING` flag covering `--upload-to-github`, `--update-appcast` and `--commit-push`, and two gates: - A failed payload build is still only a warning for a local build, where the single lost capability is injecting simulator processes. For a publishing run it is now fatal. - After export, the exported app is checked for Contents/Resources/RuntimeViewerServer-iphonesimulator.framework. This one inspects the artefact rather than the step meant to produce it, which is what makes it the stronger of the two: the embed phase reports a missing payload with `warning:` and exits 0, so a build where it never ran is indistinguishable from a good one by exit code alone. Adjudicated as PR106.16, recorded during the first review pass and left open until now. Together with PR106X.5 this closes both halves: that one keeps a stale payload out of the bundle, this one keeps a missing one out of a release.
Updates three adjudications to match what actually shipped. PR106X.10 moves from "mechanism plausible, behaviour unmeasured" to fixed, with the part that stays unmeasured stated rather than glossed: whether `.changed` fires at all in a real relaunch still depends on mDNSResponder, so the new branch may be inert. Records that the decision was traced through to the manager instead of assumed -- `onRemoved` is a deliberate no-op there, so the fix restores reachability of the new process and does not remove the stale row, which remains the accepted PR106X.2 behaviour. PR106X.6 moves from deferred to fixed, keeping the distinction the review insisted on: it lands as hygiene, not because the notarisation-rejection claim was ever substantiated. Records the evidence that made the change safe. PR106.16 moves from backlog to fixed, with both gates described, including why the post-export artefact check is the stronger one. PR106X.5's entry now names both halves of its fix, after the first version turned out to clear only the source and leave the copy already sealed into the app.
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.
Evolution 0013 plus the host-side half of its implementation. Retargeted from
maintonext: the branch is cut fromnext, and building it needs the localsibling checkouts (the daemon-side guard lives in
swift-helper-service, and the@DependencyEntrySendablefix needs the newer swift-dependencies).The PR previously described itself as documentation only. That was true of its
first commit; it now carries the implementation as well.
Why injecting a simulator process killed it
One sentence — the injector's address space is not the target's — with three
independent variants, each failing differently:
A host symbol address handed to the target.
MIMachInjector.mresolvespthread_create_from_mach_thread,dlopenandsandbox_extension_consumethrough
dlsym(RTLD_DEFAULT, ...), i.e. against the injector, then writesthose absolute addresses into the target's shellcode. That holds only while
both processes share a dyld shared cache. A simulator process runs its own,
mapped at the same
0x180000000base but laid out differently.Register-level evidence from three SpringBoard crash reports:
lris exactlyhost pthread_create_from_mach_thread + 4in all three, andx0 = sp+8,x1 = 0,x2 = page-aligned + 0x50match whatloader_arm64.ssets up forthat call. The
task_for_pid(...): (os/kern) failureshown in the UI issecond-order — by then the target was already dead.
A payload built for the wrong platform. Injection delivered the macOS
slice to every target. A macOS process and an iOS Simulator process on the
same Mac share a cputype and differ only in
LC_BUILD_VERSION's platform, sonothing about the target's architecture reveals the mismatch. dyld refuses the
slice, the daemon's remap fallback projects it in anyway, and the kernel kills
the target on page-in.
A session established over a channel the payload never dials. The attach
flow launched a client engine and waited for an XPC/socket connect-back. A
simulator payload picks its transport at compile time and always advertises
over Bonjour, so the attach timed out with a message about sandboxes that had
nothing to do with the failure.
What changed
Bonjour identity — device vs. process (
abc1a96c,b5221506)An iOS peer advertised one identity per device: service name = device name,
engine identifier = MobileGestalt UDID. The visible symptom was engines listed
under the device's name; the real damage was in the host, where
connectToBonjourEndpointdeduplicated by service name, so the secondpayload-carrying process on a device was written off as a duplicate and never
connected (measured:
gamecontrollerdESTABLISHED,nanoappregistrydstuck atLISTEN). Split into a process-level service name (
<deviceID>-<pid>, no longershown to anyone) and TXT-record display data (
rv-device-id,rv-proc-name,rv-proc-pid). The host keys dedup,pendingReconnectEndpointsand teardown byRuntimeNetworkEndpoint.uniqueKey. Result is the shape local Mac injectionalready has: one section per device, one entry per process. Peers predating the
TXT keys decode them as nil and fall back to the service name, so no
compatibility branch is needed.
Two knock-on changes:
RuntimeRemoteEngineDescriptorgainshostID(themirroring path derived it from
originChain.first, an instance ID, whichcoincided with the host only while a device ran one installation), and
RuntimeSource.identifierkeys Bonjour clients by identifier rather than name,because the name is now a display name two processes can share.
localInstanceIDno longer persists inside an injected process.Payload slice selection (
6372c334)Two deliberately separate types:
InjectionTargetPlatformProbereads what atarget is, from its executable's
LC_BUILD_VERSION— not from the executablepath, since the RuntimeRoot prefix identifies simulator daemons but a
user-built app runs from the data container with no such marker.
PayloadPlatformdescribes what we can give it, and the gap between the two is where a refusal
belongs: a tvOS Simulator process is a perfectly readable target with no slice to
offer, and now gets an error instead of the nearest slice and dyld's verdict.
The simulator slice ships as a sibling bundle,
RuntimeViewerServer-iphonesimulator.framework— the two cannot be merged, beingthe same architecture differing only in platform. It is embedded by a build phase
rather than copied into the built
.app, because everything underResourcesissealed by the app's signature and adding a file afterwards invalidates it, which
breaks SMAppService daemon registration. It cannot be a target dependency either
(Xcode rejects iOS-family embedded content from a macOS app target, the same
constraint that keeps the Catalyst helper out), so
RunScript.sh/ArchiveScript.shown the ordering and pass the payload path explicitly.Attach flow split by payload platform (
0dc1f084)attachToLocalProcesskeeps the Mac sequence and now tears down the engine itlaunched on any downstream failure.
attachToSimulatorProcessinjects and waitsfor the advertisement — no engine launched, and the sandbox probe skipped, since
it exists only to choose between XPC and localhost-socket transports.
awaitInjectedBonjourEnginematches on pid, read as the last dash-separatedcomponent of
{deviceID}-{pid}(the device half is a UUID with dashes of itsown). Its timeout error names the thing that cost the most time to discover: a
simulator payload's startup log goes to the simulator's own logd, so
xcrun simctl spawn <udid> log showshows it and the host'slog showdoes not.Build scripts (
176181dc)--local-depson both scripts, off by default, replacing a hand-exportedUSING_LOCAL_DEPENDENCIES=1. Forgetting it did not fail — the build silentlyresolved the remote pin, and the symptom was "I changed MachInjector and nothing
happened".
ArchiveScript.sh's comment says outright that a release should notuse it: main has to compile against the released pins.
Tests
InjectionTargetPlatformTests(15) — platform identification, includingagainst a real simruntime binary
PayloadPlatformTests(6)BonjourProcessIdentityTests(7) — service name / TXT round-tripsDeviceMetadataTestsVerified on a live iOS 18.5 simulator
MIMachInjector's dlopen path, not lldb): SpringBoard takes the payload,all three startup lines land, and the process stays alive. That closes the loop
this proposal opened with — SpringBoard is what was killed three times.
backboardd(2.5 MB,__objc_classlist0x558 bytes, 171 classes). Do not judge this by SpringBoard: its main binary is
a 249 KB shell with no
__objc_classlistsection at all, so an empty viewthere is the correct result — and it is the most obvious process to reach for,
which makes it a ready source of a false failure signal.
present; the app embedding it still passes
codesign --verify.Not in this PR
MITargetSymbolResolverand the daemon-side platform guard live inMachInjector/swift-helper-service; this branch is the host half. Thedaemon guard compares the payload's platform against the target's, not the
host's — writing it the way the proposal originally did would have blocked
injecting a simulator process at all.
(as opposed to a system daemon).
an error on click, not a disabled row.
Docs
Documentations/Evolutions/0013-inject-ios-simulator-process.md— In Progress,with the landing steps and a decision log covering the two proposal-level
corrections found while measuring (the symbol-offset algorithm, and the
thread-state split that turned out to be unnecessary)
Documentations/ResolvedIssues/2026-08-23-simulator-injection-host-address-fallacy.md—the root cause, its three variants, and the three diagnostic traps that send
this investigation the wrong way
Documentations/KnownIssues/2026-08-22-simulator-injection-identity-findings.md—self-audit of the identity rework: 3 regressions fixed in the same batch, 1
left alone with its trigger conditions recorded