Skip to content

Commit d0f936c

Browse files
obj-pclaude
andauthored
regress: resource-staging cluster dissolved; jit-bundle-resolution family design (#439)
* docs: re-verify B04/R02/R03 on main ahead of resource-staging family B04 unchanged. R02's blank framebuffer is gone; its Spanish assertion is a fixture defect (String(localized:locale:) cannot select an lproj). R03 macOS unchanged; iOS regressed to an agent SIGILL — the Xcode build-log capture takes an x86_64 simulator invocation that stripForeignTargetTriple accepts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * examples: R02 Spanish assertion via explicit lproj sub-bundle; row closes String(localized:bundle:locale:) does not select the .lproj (locale: affects interpolation formatting only, proven natively against the built bundle), so the 2026-07-15 fixture variant could never display Spanish even against correct staging. The title now resolves through the locale's .lproj sub-bundle with visible staging-failure states. With the mechanism corrected, all R02 surfaces render Recursos cargados (macOS control, iOS single-preview control, iOS index 1 after a live switch) and the row flips to Guard passes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * examples: B04 resource assertion via Bundle(for:); row closes Bundle.allFrameworks never lists a framework without ObjC classes (proven natively with a dlopen control), and the JSON sat under Resources/ in a flat iOS framework whose resource directory is the wrapper root. DynamicBadge gains an ObjC marker class, the payload moves to the framework root, and the preview resolves through Bundle(for:) with distinct failure states. With the mechanism corrected the iOS snapshot renders the payload — the EPC-dlopened framework serves its internal resource, so B04 flips to Guard passes. B02's combined render re-verified after the artifact change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: jit-bundle-resolution family design; R03 row updated post-#438 The resource-staging cluster dissolved under re-verification: R02 and B04 were fixture-mechanism defects, R03's iOS crash was the fat-build capture bug (#438). The one genuine gap is Bundle(for:) on JIT-compiled classes resolving to the agent image while only Generated*Symbols.swift gets the #151 wrapper rewrite. Design: an agent-side bundleForClass: hook keyed on class_getImageName == NULL, wrapper path plumbed from CODESIGNING_FOLDER_PATH; stage 2 retires the text rewrite. Amendments from adversarial review with native experiments folded: dladdr is unsound as the discriminator in a Swift process, and imageless JIT-linked SwiftPM package classes are a documented, gated limitation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: fold branch-gate review findings Drop rebase-orphaned commit SHAs from the design doc (cite rows instead), list B04's failure states by stage rather than an incomplete enumeration, and correct the DynamicBadge README's rationale — the marker anchors Bundle(for:), while the allFrameworks ObjC requirement is a separate fact. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 1609bae commit d0f936c

8 files changed

Lines changed: 253 additions & 27 deletions

File tree

docs/jit-bundle-resolution.md

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
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.

examples/regress/VERIFICATION.md

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ D09, W01's edit variant) re-verified 2026-07-15 after compile-command
77
capture landed (stage 2). Xcode rows (X01, X02, D08, R03, D01/D07 guards)
88
re-verified 2026-07-15 after build-log capture landed (stage 3). Bazel and
99
binary-framework rows (B01–B04, D04/D05 guards) re-verified 2026-07-15
10-
after aquery capture landed (stage 4).
10+
after aquery capture landed (stage 4). B04, R02, and R03 re-verified
11+
2026-07-21 ahead of the resource-staging family work.
1112

1213
Environment: Xcode 26.2, an iOS 26.3 `previewsmcp-test` iPhone simulator,
1314
and the Bazel-built CLI from this checkout. Commands used an isolated daemon
@@ -47,11 +48,11 @@ not a nonzero command exit.
4748
| 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. |
4849
| 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. |
4950
| 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. |
50-
| 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. |
51+
| 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. |
5152
| 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. |
5253
| 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). |
53-
| 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. |
54-
| 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). |
54+
| 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. |
55+
| 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. |
5556
| 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). |
5657
| 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. |
5758
| 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. |
@@ -83,6 +84,19 @@ not a nonzero command exit.
8384
- Added a single-preview Spanish resource control and made locale selection
8485
explicit in the Foundation localization call. The prior environment-only
8586
variant did not actually select Foundation's localization locale.
87+
- Corrected B04's resource assertion (2026-07-21): `Bundle.allFrameworks`
88+
never lists a framework without ObjC classes, and a flat iOS framework's
89+
resources belong at its root, not under `Resources/`. DynamicBadge now
90+
carries an ObjC marker class, the JSON moved to the framework root, and
91+
the preview resolves through `Bundle(for:)` with a distinct failure
92+
state per stage (class registration, bundle identity, resource
93+
presence, resource readability).
94+
- Replaced the Spanish assertion mechanism again (2026-07-21):
95+
`String(localized:bundle:locale:)` does not select the `.lproj` either (the
96+
`locale:` parameter affects interpolation formatting only, proven natively
97+
against the built bundle), so the title now resolves through the locale's
98+
`.lproj` sub-bundle explicitly and shows `<locale>.lproj missing` or
99+
`resource.title unresolved` when staging drops the directory or the key.
86100
- Split combined/static/dynamic/bad-slice XCFramework cases into separate
87101
SwiftPM package roots after a package-wide build let the bad slice contaminate
88102
supposedly isolated targets.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
#import <Foundation/Foundation.h>
2+
3+
@interface DynamicBadgeMarker : NSObject
4+
@end
5+
6+
@implementation DynamicBadgeMarker
7+
@end

0 commit comments

Comments
 (0)