Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 179 additions & 0 deletions docs/jit-bundle-resolution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
# JIT Bundle Resolution: user code finds the framework it was compiled from

Status: DRAFT for review — 2026-07-21

The JIT recompiles a target's sources into the agent process instead of
loading the target's built binary, so a class declared in target code lives
in the agent image, not in the framework Xcode built. `Bundle(for:)` on such
a class therefore resolves to the agent's bundle — which holds none of the
target's resources — while the real framework wrapper sits fully populated
on disk (compiled string catalogs, plists, Core Data models, asset
catalogs). One narrow carve-out exists today: generated
`Generated*Symbols.swift` files are text-rewritten to point at the wrapper
(`applyResourceBundleRewrites`, #151), which is why a generated asset color
renders while a hand-written `Bundle(for:)` lookup two lines away misses.
The fix is one rule: **a bundle lookup made by JIT-compiled code resolves to
the on-disk wrapper of the target it was compiled from, without the user's
source changing.**

## Matrix rows this retires

| Row | Remaining gap | Shared root cause |
|---|---|---|
| R03 | `resource.title` raw, plist miss, momd miss on macOS and iOS while the generated color renders | `Bundle(for: <class in the preview file>)` resolves to the agent image because the class is compiled into the JIT module (`Compiler.swift:146-176,199-272`); the rewrite that saves the color is filtered to `Generated*Symbols.swift` with an exact-needle match (`XcodeBuildSystem.swift:666-682`), so user sources never get it |

## What re-verification already closed (2026-07-21, this branch)

The resource-staging cluster began as three Reproduced rows. Two fell to
fixture-mechanism defects, not product gaps — each proven with a native
control before touching the product, and each fixed in the fixture with
distinguishable failure states:

- **R02** (SwiftPM localization): `String(localized:bundle:locale:)` never
selects an `.lproj` — the `locale:` parameter only affects interpolation
formatting. The staged bundle was healthy all along; the corrected
fixture resolves through the locale's `.lproj` sub-bundle and every
surface renders Spanish (this branch; R02's row and Fixture Corrections
carry the details).
- **B04** (XCFramework internal resource): `Bundle.allFrameworks` only
lists frameworks containing ObjC classes, and DynamicBadge was pure C —
the fixture could not observe the framework under any product behavior;
its JSON also sat under `Resources/` in a flat iOS framework. With an
ObjC marker class and a root-level resource, the EPC-dlopened framework
resolves via `Bundle(for:)` and serves the payload (this branch; B04's
row and Fixture Corrections carry the details).
This also establishes the load path is sound: a **real** dynamic
framework loaded from the build directory keeps its wrapper identity in
the agent.
- **R03's iOS crash** was a separate defect — the fat-build x86_64 capture
— fixed on main (#438).

The lesson the family keeps: verify a row's assertion mechanism natively
(outside the daemon) before reading it as a product gap.

## Today's shape (evidence)

- **Target code is recompiled, never loaded.** The preview file compiles as
the overlay and the target's remaining sources as the stable module, both
to fresh objects the JIT materializes into the agent
(`Compiler.swift:146-176,199-272`); "the target's own framework is the
Tier 2 recompile itself, never loaded"
(`XcodeBuildSystem.swift:255-256`). A class compiled this way has no dyld
image inside the framework wrapper, so Foundation resolves
`Bundle(for:)` to the agent bundle.
- **The resources exist.** Both platforms' built products contain
`Assets.car`, `en.lproj/Localizable.strings` and
`es.lproj/Localizable.strings` (compiled from the string catalog),
`FixtureInfo.plist`, and `FixtureModel.momd` inside
`XcodeResources.framework` — verified on disk for Debug and
Debug-iphonesimulator. The misses are lookup misses, not staging misses.
- **The carve-out that proves the rule.** `applyResourceBundleRewrites`
(`XcodeBuildSystem.swift:631-656`) rewrites sources whose name matches
`Generated*Symbols.swift` and whose body contains the generator's exact
`ResourceBundleClass` preamble (`:666-682`), substituting
`Bundle(path: <CODESIGNING_FOLDER_PATH>)` (`:685-693`). The wrapper path
already rides build settings on both platforms and encodes the
macOS-versioned vs iOS-flat layout difference. User code fails every
filter by construction.
- **Wrapper layout differs per platform.** macOS: versioned bundle,
`CODESIGNING_FOLDER_PATH` ends in `.framework/Versions/A`. iOS: flat
bundle. Any fix must use the setting verbatim rather than assume a
layout.

## Design: an agent-side `bundleForClass:` fallback

Rewriting arbitrary user sources would generalize the carve-out but is
brittle text surgery on code we do not control (arbitrary token names,
arbitrary lookup spellings — `Bundle(for:)`, `Bundle(identifier:)`,
`.main`). The durable seam is where resolution happens: Foundation's
`+[NSBundle bundleForClass:]` in the agent process.

Rule: when the daemon knows the target's wrapper path, the agent installs a
`bundleForClass:` hook. The hook calls the original; if the original
resolved to the agent's own bundle **and** the class carries no image
identity, it returns the wrapper bundle instead. Classes from real images —
the agent's own, dlopen'd dependency frameworks (B04's case), system
frameworks — hit the original path unchanged.

The discriminator is `class_getImageName(cls) == NULL`, **not** `dladdr`.
Adversarial review (2026-07-21, native experiments) showed `dladdr` on a
class pointer is placement-based nearest-symbol lookup: in a Swift process
it attributes runtime-allocated class metadata — and even real system
classes — to `libswiftCore`'s allocation pool, so it cannot discriminate.
`class_getImageName` stayed NULL for imageless classes and correct for
every real class (pure Swift included). The same review verified the
metaclass swizzle fires for Swift's `Bundle(for:)` on the Xcode 26.2 SDK,
that Foundation's bundle-for-class cache sits below the swizzle (a
late-installed hook is not bypassed by earlier lookups), and that
`Bundle(path:)` on `CODESIGNING_FOLDER_PATH` serves resources for both the
versioned and flat layouts. One measurement remains before the predicate is
final: what `class_getImageName` returns for a **real ORC-materialized**
class in the agent (the review's proxy used `objc_allocateClassPair`).
Stage 1 opens with that diagnostic; if ORC stamps JIT classes with the
agent's own executable path, the predicate degrades to
`original == Bundle.main`, which accepts redirecting agent-image lookups
as the documented cost.

- **Plumbing:** `BuildContext` gains the optional wrapper path (Xcode
targets: `CODESIGNING_FOLDER_PATH`; SPM/Bazel: nil — SwiftPM's generated
`Bundle.module` accessor already finds the built bundle beside the
products, proven by R02). The session passes it to the agent with the
render request, the same route the crash-notice and setup sidecars ride.
- **Scope:** one target per session, so one wrapper per agent process at a
time; the hook re-arms per session start.
- **What it fixes:** `Bundle(for:)` on any class in JIT-compiled target
code — which also fixes `String(localized:bundle:)` and Core Data
`momd` lookups made against that bundle (R03's three misses).
- **What it deliberately does not touch:** `Bundle.main` (the agent's own
identity, used by the JIT runtime), `Bundle.module` in SPM targets
(already correct), lookups from real dylib images.
- **Known limitation (documented, gated):** Xcode-managed SwiftPM package
products are JIT-linked as archives (`swiftPMPackageProducts`,
`XcodeBuildSystem.swift:907-963`), so a package's classes are imageless
too — the hook would misdirect a package-code `Bundle(for:)` to the
*target's* wrapper. No current matrix row exercises an Xcode target
embedding a resource-bearing package; that row must exist before the
combination ships. The hook installs only when a wrapper is configured
(the Xcode path), so pure-SPM sessions (R02) are inert by construction —
stage 1's manual pass re-runs R02 with the hook code present to prove
it.

## Implementation stages

Stages follow the family discipline: design → adversarial review → gates
(/simplify, /code-review, unit tier, integration tier) → manual matrix
re-verification flipping rows in VERIFICATION.md.

1. **Wrapper plumbing + agent hook.** Opens with the provenance
diagnostic: dump `class_getImageName` and `Bundle(for:)` identity for a
real ORC-materialized class in the macOS agent, deciding the predicate
(see Design). Then `BuildContext.resourceWrapperPath`, the
render-request sidecar, and the `bundleForClass:` hook behind it
(macOS agent and iOS agent app). Unit rows pin the hook's decision
table: imageless class + wrapper → wrapper bundle; real-image class →
original; no wrapper configured → original. Manual: **R03 flips** —
title `Xcode resources loaded`, plist loaded, Core Data model loaded,
color still renders, macOS and iOS; R02 re-run with the hook code
present (must stay inert and render Spanish); B02/B03/B04 and X01/X02
guards hold (dependency-framework classes must keep resolving to their
own wrappers). iOS must-verifies from review: the swizzle fires in the
agent-app process, `Bundle(path:)` on the host wrapper path resolves
in-sim, and a JIT class's original lookup lands on the agent app's
`Bundle.main`.
2. **Retire the text rewrite.** With the hook in place the
`Generated*Symbols.swift` rewrite is redundant on the Xcode path —
remove `applyResourceBundleRewrites` and its rewrite directory, keep
the tests that pin the generated color rendering. Only after stage 1's
flake record is clean; the rewrite is proven and the hook must earn
the same trust first.

## Out of scope

- R01's render half (the `LC_LINKER_OPTION` autolink scan → `addDylib`):
named future work, unchanged by this family.
- Bazel target resource bundles: no matrix row exercises them; add a row
before designing.
- Xcode-style asset-symbol generation for SPM/Bazel targets: different
feature, different family.
- Pinning the Xcode build to one arch (`ARCHS=<hostArch>`): named future
work from #438; read-side capture already tolerates fat builds.
22 changes: 18 additions & 4 deletions examples/regress/VERIFICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ D09, W01's edit variant) re-verified 2026-07-15 after compile-command
capture landed (stage 2). Xcode rows (X01, X02, D08, R03, D01/D07 guards)
re-verified 2026-07-15 after build-log capture landed (stage 3). Bazel and
binary-framework rows (B01–B04, D04/D05 guards) re-verified 2026-07-15
after aquery capture landed (stage 4).
after aquery capture landed (stage 4). B04, R02, and R03 re-verified
2026-07-21 ahead of the resource-staging family work.

Environment: Xcode 26.2, an iOS 26.3 `previewsmcp-test` iPhone simulator,
and the Bazel-built CLI from this checkout. Commands used an isolated daemon
Expand Down Expand Up @@ -47,11 +48,11 @@ not a nonzero command exit.
| B01 | Guard passes | 2026-07-15 (Bazel aquery capture): rendered `Canonical Bzlmod repository` and `Bazel generated source`. The SwiftCompile action's arguments carry the generated source at its execroot path and the canonical external repo's module search path; external dependency archives are force-built for the JIT link. |
| B02 | Guard passes | 2026-07-15 (compile capture): rendered both `Static simulator XCFramework` and `Dynamic simulator XCFramework` on iOS — the captured flags resolve the StaticBadge module and the copied static archive in binPath links alongside the dynamic framework. |
| B03 | Guard passes | 2026-07-15 (compile capture): rendered `Static simulator XCFramework` on iOS; the static XCFramework's module resolves from the captured flags and its copied `libStaticBadge.a` links from binPath. |
| B04 | Reproduced | The dynamic-only package passed natively and PreviewsMCP loaded it on iOS: the snapshot rendered `Dynamic simulator XCFramework`. The same snapshot reported `framework resource missing`, so the framework's internal JSON was not staged with the loaded binary. |
| B04 | Guard passes | 2026-07-21: with the fixture's lookup corrected (see Fixture Corrections), the iOS snapshot renders both the framework message and the internal JSON payload — the EPC-dlopened framework resolves via `Bundle(for:)` and serves its root-level resource, so nothing was ever missing from staging. The recorded `framework resource missing` was the fixture's own mechanism twice over: `Bundle.allFrameworks` only lists frameworks containing at least one ObjC class (DynamicBadge was pure C, so it could never be enumerated — proven natively with a dlopen control), and the JSON sat in a `Resources/` subdirectory of a flat iOS framework. B02's combined render re-verified after the artifact change. |
| F01 | Guard passes | 2026-07-20 (phase/error stage 4): the iOS start returns the classified error `XCFramework 'BadSlice' has no iOS simulator slice (available: ios-arm64).` with a rebuild remediation — the enricher reads the declared binary target's `Info.plist` when a `no such module` names it, and any miss degrades to the plain build failure. Daemon stays responsive. |
| R01 | Guard passes | 2026-07-20 (phase/error stage 4): the start returns a classified session error — `Rendering the preview failed: JIT link could not resolve 3 symbol(s): _SCNVector3Zero, _OBJC_CLASS_$_SCNScene, _OBJC_CLASS_$_LPLinkMetadata` — naming the autolink closure's actual symbols, with the bounded list and an autolink remediation. The daemon stays responsive; rendering the closure remains named future work (`LC_LINKER_OPTION` scan). |
| R02 | Reproduced | English JSON, text, and localization resources rendered on macOS and iOS. Both selecting preview index 1 and an explicit single-preview Spanish localization control produced a blank or partial framebuffer on iOS. Native SwiftPM build passed and both locale directories were present in the staged bundle. |
| R03 | Reproduced | macOS and iOS rendered the generated color symbol, while the localized key remained `resource.title` and plist/Core Data lookup reported missing. The cold iOS Xcode build also spent about 49 seconds in one progress step. Re-verified 2026-07-15 under Xcode compile capture: the generated-sources half renders identically; the remaining gap is runtime-resource staging (out of the resolver's scope). |
| R02 | Guard passes | 2026-07-21: the original blank/partial framebuffer no longer reproduces, and with the fixture's Spanish assertion corrected (see Fixture Corrections) every surface renders `Recursos cargados` — the macOS control, the iOS single-preview control, and iOS index 1 after a live switch — alongside the JSON and text rows. The re-verification first found all surfaces rendering the English title, but a native harness against the healthy built bundle proved that was the fixture's own mechanism: `String(localized:bundle:locale:)` does not select the `.lproj` (its `locale:` parameter affects interpolation formatting only), so the 2026-07-15 fixture variant could never display Spanish even against correct staging. Original 2026-07-14 observation, for history: index 1 and the Spanish control produced a blank or partial framebuffer on iOS while the native build passed with both locale directories staged. |
| R03 | Reproduced | macOS and iOS rendered the generated color symbol, while the localized key remained `resource.title` and plist/Core Data lookup reported missing. The cold iOS Xcode build also spent about 49 seconds in one progress step. Re-verified 2026-07-15 under Xcode compile capture: the generated-sources half renders identically; the remaining gap is runtime-resource staging (out of the resolver's scope). Re-verified 2026-07-21: macOS unchanged (the generated color renders; `resource.title`, plist, and Core Data model still miss). iOS had regressed to a deterministic agent SIGILL — the generic `iOS Simulator` destination builds every arch and the build-log capture could return the x86_64 swift-frontend invocation — fixed the same day (#438, host-arch capture preference plus the foreign-arch triple strip); after the fix iOS renders the macOS-identical partial baseline again. The remaining gap on both platforms: `Bundle(for:)` on a class compiled into the JIT resolves to the agent process image, and only `Generated*Symbols.swift` files are rewritten to the framework wrapper (`applyResourceBundleRewrites`), so user-code bundle lookups miss resources that are present on disk in the built framework. |
| W01 | Partial guard | Editing a dependency Swift file live changed `source version one` to `source version two` in a stable follow-up snapshot without restarting the session. The add/rename/remove variants are present in the fixture instructions but were not all exercised in this pass. Edit variant re-verified 2026-07-15 with the watcher fed by captured compile inputs; the fixture's Swift 6 language mode also forced two generated-source concurrency fixes (DesignTimeStore, window-state observer). |
| W02 | Guard passes | 2026-07-16 (state-invalidation stage 4): a resource-only edit to `Resources/payload.json` fired the runtime-input tier — the daemon logged `Evidence change: re-running the native build` — and a stable follow-up snapshot rendered the new value; the revert refreshed back. Regression note on the original observation: on stage-3 code, neither an in-place nor an atomic-rename resource-only edit produces any watcher activity (the resource path cannot pass the exact-path filter), and a snapshot logs only clean MCP lines — the originally recorded "reload transition" therefore came from the operator's editor re-saving an open watched source file in the same burst, not from the resource edit. |
| W03 | Guard passes | Both editor save styles reloaded on macOS: write-temp-then-rename-over and rename-away-then-recreate each updated the render to the new source value in a stable follow-up snapshot. |
Expand Down Expand Up @@ -83,6 +84,19 @@ not a nonzero command exit.
- Added a single-preview Spanish resource control and made locale selection
explicit in the Foundation localization call. The prior environment-only
variant did not actually select Foundation's localization locale.
- Corrected B04's resource assertion (2026-07-21): `Bundle.allFrameworks`
never lists a framework without ObjC classes, and a flat iOS framework's
resources belong at its root, not under `Resources/`. DynamicBadge now
carries an ObjC marker class, the JSON moved to the framework root, and
the preview resolves through `Bundle(for:)` with a distinct failure
state per stage (class registration, bundle identity, resource
presence, resource readability).
- Replaced the Spanish assertion mechanism again (2026-07-21):
`String(localized:bundle:locale:)` does not select the `.lproj` either (the
`locale:` parameter affects interpolation formatting only, proven natively
against the built bundle), so the title now resolves through the locale's
`.lproj` sub-bundle explicitly and shows `<locale>.lproj missing` or
`resource.title unresolved` when staging drops the directory or the key.
- Split combined/static/dynamic/bad-slice XCFramework cases into separate
SwiftPM package roots after a package-wide build let the bad slice contaminate
supposedly isolated targets.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#import <Foundation/Foundation.h>

@interface DynamicBadgeMarker : NSObject
@end

@implementation DynamicBadgeMarker
@end
Loading