|
| 1 | +# JIT Bundle Resolution: user code finds the framework it was compiled from |
| 2 | + |
| 3 | +Status: DRAFT for review — 2026-07-21 |
| 4 | + |
| 5 | +The JIT recompiles a target's sources into the agent process instead of |
| 6 | +loading the target's built binary, so a class declared in target code lives |
| 7 | +in the agent image, not in the framework Xcode built. `Bundle(for:)` on such |
| 8 | +a class therefore resolves to the agent's bundle — which holds none of the |
| 9 | +target's resources — while the real framework wrapper sits fully populated |
| 10 | +on disk (compiled string catalogs, plists, Core Data models, asset |
| 11 | +catalogs). One narrow carve-out exists today: generated |
| 12 | +`Generated*Symbols.swift` files are text-rewritten to point at the wrapper |
| 13 | +(`applyResourceBundleRewrites`, #151), which is why a generated asset color |
| 14 | +renders while a hand-written `Bundle(for:)` lookup two lines away misses. |
| 15 | +The fix is one rule: **a bundle lookup made by JIT-compiled code resolves to |
| 16 | +the on-disk wrapper of the target it was compiled from, without the user's |
| 17 | +source changing.** |
| 18 | + |
| 19 | +## Matrix rows this retires |
| 20 | + |
| 21 | +| Row | Remaining gap | Shared root cause | |
| 22 | +|---|---|---| |
| 23 | +| 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 | |
| 24 | + |
| 25 | +## What re-verification already closed (2026-07-21, this branch) |
| 26 | + |
| 27 | +The resource-staging cluster began as three Reproduced rows. Two fell to |
| 28 | +fixture-mechanism defects, not product gaps — each proven with a native |
| 29 | +control before touching the product, and each fixed in the fixture with |
| 30 | +distinguishable failure states: |
| 31 | + |
| 32 | +- **R02** (SwiftPM localization): `String(localized:bundle:locale:)` never |
| 33 | + selects an `.lproj` — the `locale:` parameter only affects interpolation |
| 34 | + formatting. The staged bundle was healthy all along; the corrected |
| 35 | + fixture resolves through the locale's `.lproj` sub-bundle and every |
| 36 | + surface renders Spanish (this branch; R02's row and Fixture Corrections |
| 37 | + carry the details). |
| 38 | +- **B04** (XCFramework internal resource): `Bundle.allFrameworks` only |
| 39 | + lists frameworks containing ObjC classes, and DynamicBadge was pure C — |
| 40 | + the fixture could not observe the framework under any product behavior; |
| 41 | + its JSON also sat under `Resources/` in a flat iOS framework. With an |
| 42 | + ObjC marker class and a root-level resource, the EPC-dlopened framework |
| 43 | + resolves via `Bundle(for:)` and serves the payload (this branch; B04's |
| 44 | + row and Fixture Corrections carry the details). |
| 45 | + This also establishes the load path is sound: a **real** dynamic |
| 46 | + framework loaded from the build directory keeps its wrapper identity in |
| 47 | + the agent. |
| 48 | +- **R03's iOS crash** was a separate defect — the fat-build x86_64 capture |
| 49 | + — fixed on main (#438). |
| 50 | + |
| 51 | +The lesson the family keeps: verify a row's assertion mechanism natively |
| 52 | +(outside the daemon) before reading it as a product gap. |
| 53 | + |
| 54 | +## Today's shape (evidence) |
| 55 | + |
| 56 | +- **Target code is recompiled, never loaded.** The preview file compiles as |
| 57 | + the overlay and the target's remaining sources as the stable module, both |
| 58 | + to fresh objects the JIT materializes into the agent |
| 59 | + (`Compiler.swift:146-176,199-272`); "the target's own framework is the |
| 60 | + Tier 2 recompile itself, never loaded" |
| 61 | + (`XcodeBuildSystem.swift:255-256`). A class compiled this way has no dyld |
| 62 | + image inside the framework wrapper, so Foundation resolves |
| 63 | + `Bundle(for:)` to the agent bundle. |
| 64 | +- **The resources exist.** Both platforms' built products contain |
| 65 | + `Assets.car`, `en.lproj/Localizable.strings` and |
| 66 | + `es.lproj/Localizable.strings` (compiled from the string catalog), |
| 67 | + `FixtureInfo.plist`, and `FixtureModel.momd` inside |
| 68 | + `XcodeResources.framework` — verified on disk for Debug and |
| 69 | + Debug-iphonesimulator. The misses are lookup misses, not staging misses. |
| 70 | +- **The carve-out that proves the rule.** `applyResourceBundleRewrites` |
| 71 | + (`XcodeBuildSystem.swift:631-656`) rewrites sources whose name matches |
| 72 | + `Generated*Symbols.swift` and whose body contains the generator's exact |
| 73 | + `ResourceBundleClass` preamble (`:666-682`), substituting |
| 74 | + `Bundle(path: <CODESIGNING_FOLDER_PATH>)` (`:685-693`). The wrapper path |
| 75 | + already rides build settings on both platforms and encodes the |
| 76 | + macOS-versioned vs iOS-flat layout difference. User code fails every |
| 77 | + filter by construction. |
| 78 | +- **Wrapper layout differs per platform.** macOS: versioned bundle, |
| 79 | + `CODESIGNING_FOLDER_PATH` ends in `.framework/Versions/A`. iOS: flat |
| 80 | + bundle. Any fix must use the setting verbatim rather than assume a |
| 81 | + layout. |
| 82 | + |
| 83 | +## Design: an agent-side `bundleForClass:` fallback |
| 84 | + |
| 85 | +Rewriting arbitrary user sources would generalize the carve-out but is |
| 86 | +brittle text surgery on code we do not control (arbitrary token names, |
| 87 | +arbitrary lookup spellings — `Bundle(for:)`, `Bundle(identifier:)`, |
| 88 | +`.main`). The durable seam is where resolution happens: Foundation's |
| 89 | +`+[NSBundle bundleForClass:]` in the agent process. |
| 90 | + |
| 91 | +Rule: when the daemon knows the target's wrapper path, the agent installs a |
| 92 | +`bundleForClass:` hook. The hook calls the original; if the original |
| 93 | +resolved to the agent's own bundle **and** the class carries no image |
| 94 | +identity, it returns the wrapper bundle instead. Classes from real images — |
| 95 | +the agent's own, dlopen'd dependency frameworks (B04's case), system |
| 96 | +frameworks — hit the original path unchanged. |
| 97 | + |
| 98 | +The discriminator is `class_getImageName(cls) == NULL`, **not** `dladdr`. |
| 99 | +Adversarial review (2026-07-21, native experiments) showed `dladdr` on a |
| 100 | +class pointer is placement-based nearest-symbol lookup: in a Swift process |
| 101 | +it attributes runtime-allocated class metadata — and even real system |
| 102 | +classes — to `libswiftCore`'s allocation pool, so it cannot discriminate. |
| 103 | +`class_getImageName` stayed NULL for imageless classes and correct for |
| 104 | +every real class (pure Swift included). The same review verified the |
| 105 | +metaclass swizzle fires for Swift's `Bundle(for:)` on the Xcode 26.2 SDK, |
| 106 | +that Foundation's bundle-for-class cache sits below the swizzle (a |
| 107 | +late-installed hook is not bypassed by earlier lookups), and that |
| 108 | +`Bundle(path:)` on `CODESIGNING_FOLDER_PATH` serves resources for both the |
| 109 | +versioned and flat layouts. One measurement remains before the predicate is |
| 110 | +final: what `class_getImageName` returns for a **real ORC-materialized** |
| 111 | +class in the agent (the review's proxy used `objc_allocateClassPair`). |
| 112 | +Stage 1 opens with that diagnostic; if ORC stamps JIT classes with the |
| 113 | +agent's own executable path, the predicate degrades to |
| 114 | +`original == Bundle.main`, which accepts redirecting agent-image lookups |
| 115 | +as the documented cost. |
| 116 | + |
| 117 | +- **Plumbing:** `BuildContext` gains the optional wrapper path (Xcode |
| 118 | + targets: `CODESIGNING_FOLDER_PATH`; SPM/Bazel: nil — SwiftPM's generated |
| 119 | + `Bundle.module` accessor already finds the built bundle beside the |
| 120 | + products, proven by R02). The session passes it to the agent with the |
| 121 | + render request, the same route the crash-notice and setup sidecars ride. |
| 122 | +- **Scope:** one target per session, so one wrapper per agent process at a |
| 123 | + time; the hook re-arms per session start. |
| 124 | +- **What it fixes:** `Bundle(for:)` on any class in JIT-compiled target |
| 125 | + code — which also fixes `String(localized:bundle:)` and Core Data |
| 126 | + `momd` lookups made against that bundle (R03's three misses). |
| 127 | +- **What it deliberately does not touch:** `Bundle.main` (the agent's own |
| 128 | + identity, used by the JIT runtime), `Bundle.module` in SPM targets |
| 129 | + (already correct), lookups from real dylib images. |
| 130 | +- **Known limitation (documented, gated):** Xcode-managed SwiftPM package |
| 131 | + products are JIT-linked as archives (`swiftPMPackageProducts`, |
| 132 | + `XcodeBuildSystem.swift:907-963`), so a package's classes are imageless |
| 133 | + too — the hook would misdirect a package-code `Bundle(for:)` to the |
| 134 | + *target's* wrapper. No current matrix row exercises an Xcode target |
| 135 | + embedding a resource-bearing package; that row must exist before the |
| 136 | + combination ships. The hook installs only when a wrapper is configured |
| 137 | + (the Xcode path), so pure-SPM sessions (R02) are inert by construction — |
| 138 | + stage 1's manual pass re-runs R02 with the hook code present to prove |
| 139 | + it. |
| 140 | + |
| 141 | +## Implementation stages |
| 142 | + |
| 143 | +Stages follow the family discipline: design → adversarial review → gates |
| 144 | +(/simplify, /code-review, unit tier, integration tier) → manual matrix |
| 145 | +re-verification flipping rows in VERIFICATION.md. |
| 146 | + |
| 147 | +1. **Wrapper plumbing + agent hook.** Opens with the provenance |
| 148 | + diagnostic: dump `class_getImageName` and `Bundle(for:)` identity for a |
| 149 | + real ORC-materialized class in the macOS agent, deciding the predicate |
| 150 | + (see Design). Then `BuildContext.resourceWrapperPath`, the |
| 151 | + render-request sidecar, and the `bundleForClass:` hook behind it |
| 152 | + (macOS agent and iOS agent app). Unit rows pin the hook's decision |
| 153 | + table: imageless class + wrapper → wrapper bundle; real-image class → |
| 154 | + original; no wrapper configured → original. Manual: **R03 flips** — |
| 155 | + title `Xcode resources loaded`, plist loaded, Core Data model loaded, |
| 156 | + color still renders, macOS and iOS; R02 re-run with the hook code |
| 157 | + present (must stay inert and render Spanish); B02/B03/B04 and X01/X02 |
| 158 | + guards hold (dependency-framework classes must keep resolving to their |
| 159 | + own wrappers). iOS must-verifies from review: the swizzle fires in the |
| 160 | + agent-app process, `Bundle(path:)` on the host wrapper path resolves |
| 161 | + in-sim, and a JIT class's original lookup lands on the agent app's |
| 162 | + `Bundle.main`. |
| 163 | +2. **Retire the text rewrite.** With the hook in place the |
| 164 | + `Generated*Symbols.swift` rewrite is redundant on the Xcode path — |
| 165 | + remove `applyResourceBundleRewrites` and its rewrite directory, keep |
| 166 | + the tests that pin the generated color rendering. Only after stage 1's |
| 167 | + flake record is clean; the rewrite is proven and the hook must earn |
| 168 | + the same trust first. |
| 169 | + |
| 170 | +## Out of scope |
| 171 | + |
| 172 | +- R01's render half (the `LC_LINKER_OPTION` autolink scan → `addDylib`): |
| 173 | + named future work, unchanged by this family. |
| 174 | +- Bazel target resource bundles: no matrix row exercises them; add a row |
| 175 | + before designing. |
| 176 | +- Xcode-style asset-symbol generation for SPM/Bazel targets: different |
| 177 | + feature, different family. |
| 178 | +- Pinning the Xcode build to one arch (`ARCHS=<hostArch>`): named future |
| 179 | + work from #438; read-side capture already tolerates fat builds. |
0 commit comments