Skip to content

Inject into iOS Simulator processes (Evolution 0013) - #106

Open
Mx-Iris wants to merge 28 commits into
nextfrom
feature/inject-ios-simulator-process
Open

Inject into iOS Simulator processes (Evolution 0013)#106
Mx-Iris wants to merge 28 commits into
nextfrom
feature/inject-ios-simulator-process

Conversation

@Mx-Iris

@Mx-Iris Mx-Iris commented Aug 18, 2026

Copy link
Copy Markdown
Member

Evolution 0013 plus the host-side half of its implementation. Retargeted from
main to next: the branch is cut from next, and building it needs the local
sibling checkouts (the daemon-side guard lives in swift-helper-service, and the
@DependencyEntry Sendable fix 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:

  1. A host symbol address handed to the target. MIMachInjector.m resolves
    pthread_create_from_mach_thread, dlopen and sandbox_extension_consume
    through dlsym(RTLD_DEFAULT, ...), i.e. against the injector, then writes
    those 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 0x180000000 base but laid out differently.

    Register-level evidence from three SpringBoard crash reports: lr is exactly
    host pthread_create_from_mach_thread + 4 in all three, and x0 = sp+8,
    x1 = 0, x2 = page-aligned + 0x50 match what loader_arm64.s sets up for
    that call. The task_for_pid(...): (os/kern) failure shown in the UI is
    second-order — by then the target was already dead.

  2. 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, so
    nothing 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.

  3. 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
connectToBonjourEndpoint deduplicated by service name, so the second
payload-carrying process on a device was written off as a duplicate and never
connected (measured: gamecontrollerd ESTABLISHED, nanoappregistryd stuck at
LISTEN). Split into a process-level service name (<deviceID>-<pid>, no longer
shown to anyone) and TXT-record display data (rv-device-id, rv-proc-name,
rv-proc-pid). The host keys dedup, pendingReconnectEndpoints and teardown by
RuntimeNetworkEndpoint.uniqueKey. Result is the shape local Mac injection
already 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: RuntimeRemoteEngineDescriptor gains hostID (the
mirroring path derived it from originChain.first, an instance ID, which
coincided with the host only while a device ran one installation), and
RuntimeSource.identifier keys Bonjour clients by identifier rather than name,
because the name is now a display name two processes can share.
localInstanceID no longer persists inside an injected process.

Payload slice selection (6372c334)
Two deliberately separate types: InjectionTargetPlatformProbe reads what a
target is, from its executable's LC_BUILD_VERSION — not from the executable
path, since the RuntimeRoot prefix identifies simulator daemons but a
user-built app runs from the data container with no such marker. PayloadPlatform
describes 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, being
the same architecture differing only in platform. It is embedded by a build phase
rather than copied into the built .app, because everything under Resources is
sealed 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.sh own the ordering and pass the payload path explicitly.

Attach flow split by payload platform (0dc1f084)
attachToLocalProcess keeps the Mac sequence and now tears down the engine it
launched on any downstream failure. attachToSimulatorProcess injects and waits
for the advertisement — no engine launched, and the sandbox probe skipped, since
it exists only to choose between XPC and localhost-socket transports.
awaitInjectedBonjourEngine matches on pid, read as the last dash-separated
component of {deviceID}-{pid} (the device half is a UUID with dashes of its
own). 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 show shows it and the host's log show does not.

Build scripts (176181dc)
--local-deps on both scripts, off by default, replacing a hand-exported
USING_LOCAL_DEPENDENCIES=1. Forgetting it did not fail — the build silently
resolved the remote pin, and the symptom was "I changed MachInjector and nothing
happened". ArchiveScript.sh's comment says outright that a release should not
use it: main has to compile against the released pins.

Tests

  • InjectionTargetPlatformTests (15) — platform identification, including
    against a real simruntime binary
  • PayloadPlatformTests (6)
  • BonjourProcessIdentityTests (7) — service name / TXT round-trips
  • one added case in DeviceMetadataTests

Verified on a live iOS 18.5 simulator

  • Injection, through the product path (Attach to Process → daemon →
    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.
  • Interface browsing: verified on backboardd (2.5 MB, __objc_classlist
    0x558 bytes, 171 classes). Do not judge this by SpringBoard: its main binary is
    a 249 KB shell with no __objc_classlist section at all, so an empty view
    there 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.
  • The built slice is platform 7 / arm64 / ad-hoc signed with both entry symbols
    present; the app embedding it still passes codesign --verify.

Not in this PR

  • MITargetSymbolResolver and the daemon-side platform guard live in
    MachInjector / swift-helper-service; this branch is the host half. The
    daemon 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.
  • Still unverified end to end: disconnect, re-inject, and a user-installed app
    (as opposed to a system daemon).
  • Greying out unavailable targets in the Attach to Process list — today you get
    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

Copilot AI lite review requested due to automatic review settings August 18, 2026 10:22

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Mx-Iris added 12 commits August 22, 2026 21:03
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
Mx-Iris force-pushed the feature/inject-ios-simulator-process branch from 65686dc to 69d8131 Compare August 23, 2026 14:29
@Mx-Iris Mx-Iris changed the title Proposal 0013 — inject into iOS Simulator processes Inject into iOS Simulator processes (Evolution 0013) Aug 24, 2026
@Mx-Iris
Mx-Iris changed the base branch from main to next August 24, 2026 02:54
Mx-Iris added 11 commits August 24, 2026 18:23
`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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants