Skip to content

Commit e62ad2d

Browse files
obj-pclaude
andauthored
core+agents: JIT bundle resolution stage 1 — R03 flips (#440)
Imageless JIT-compiled classes get their Bundle(for:) redirected to the target's built framework wrapper. The hook lives in each agent binary (BundleRedirect objc_library on macOS, AgentApp.swift mirror on iOS) because bridge generations can be torn down while the swizzled IMP must outlive them; the bridge calls previewsmcp_set_resource_wrapper via dlsym before each render, installing once per process and refreshing the path. The wrapper rides BuildContext.resourceWrapperPath from CODESIGNING_FOLDER_PATH on both Xcode capture paths; SwiftPM and Bazel stay nil so the hook never installs there. The stage-opening diagnostic (probe preview through the real JIT) decided the predicate: ORC-materialized classes report class_getImageName == NULL with Bundle(for:) falling back to the agent's main bundle, so the hook redirects exactly that combination. Manual matrix: R03 flips on macOS and iOS (localized title, plist, Core Data model, generated color); R02 stays Spanish with the hook code present; B04's real-image framework class keeps its own wrapper. Unit rows pin the hook decision table (BundleRedirectTests) and the generator emission (BridgeGeneratorRedirectTests); the iOS agent builder hash is re-pinned. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent d0f936c commit e62ad2d

14 files changed

Lines changed: 363 additions & 24 deletions

File tree

docs/jit-bundle-resolution.md

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -106,27 +106,43 @@ metaclass swizzle fires for Swift's `Bundle(for:)` on the Xcode 26.2 SDK,
106106
that Foundation's bundle-for-class cache sits below the swizzle (a
107107
late-installed hook is not bypassed by earlier lookups), and that
108108
`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.
109+
versioned and flat layouts. The predicate is decided (stage 1, 2026-07-21): a probe preview rendered
110+
through the real JIT showed the ORC-materialized class reports
111+
`class_getImageName == NULL` with `Bundle(for:)` falling back to the
112+
agent's main bundle — the clean discriminator holds, no degraded
113+
fallback needed. The hook combines both signals: redirect only when the
114+
original resolution is the main bundle **and** the class is imageless.
116115

117116
- **Plumbing:** `BuildContext` gains the optional wrapper path (Xcode
118117
targets: `CODESIGNING_FOLDER_PATH`; SPM/Bazel: nil — SwiftPM's generated
119118
`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.
119+
products, proven by R02). The generated bridge declares the agent's
120+
setter via `@_silgen_name` (the `set_preview_vc` precedent) and calls it
121+
before setup and before every render — **unconditionally**: a nil
122+
wrapper emits a clearing `nil` call, so an agent that survives a
123+
mid-session build-system identity flip (Xcode → SwiftPM re-detection on
124+
a live session, where the agent process persists across the refresh)
125+
cannot keep a stale path. The call precedes `previewSetUp`, so setup
126+
plugins that load resources see the redirect too.
122127
- **Scope:** one target per session, so one wrapper per agent process at a
123-
time; the hook re-arms per session start.
128+
time; the hook re-arms per session start. Each session gets a fresh
129+
agent process on both platforms (macOS spawns per session; iOS
130+
pre-launch-terminates), so cross-session staleness cannot occur even
131+
without the clearing call — the clearing call covers the within-session
132+
refresh case.
124133
- **What it fixes:** `Bundle(for:)` on any class in JIT-compiled target
125134
code — which also fixes `String(localized:bundle:)` and Core Data
126135
`momd` lookups made against that bundle (R03's three misses).
127136
- **What it deliberately does not touch:** `Bundle.main` (the agent's own
128137
identity, used by the JIT runtime), `Bundle.module` in SPM targets
129138
(already correct), lookups from real dylib images.
139+
- **Known over-redirection (accepted):** runtime-generated imageless
140+
classes that resolve to the main bundle — KVO's `NSKVONotifying_*`
141+
dynamic subclasses, other `objc_allocateClassPair` products — match the
142+
predicate and redirect to the wrapper. For a KVO subclass of a target
143+
class that answer is arguably more correct than the main bundle; for
144+
agent-class KVO subclasses it is a harmless miss into a valid bundle.
145+
No crash path either way.
130146
- **Known limitation (documented, gated):** Xcode-managed SwiftPM package
131147
products are JIT-linked as archives (`swiftPMPackageProducts`,
132148
`XcodeBuildSystem.swift:907-963`), so a package's classes are imageless
@@ -166,6 +182,13 @@ re-verification flipping rows in VERIFICATION.md.
166182
the tests that pin the generated color rendering. Only after stage 1's
167183
flake record is clean; the rewrite is proven and the hook must earn
168184
the same trust first.
185+
3. **Automated R03 guard.** The redirect's load-bearing premise (real
186+
ORC-materialized classes are imageless) is pinned only by
187+
`BundleRedirectTests`' `objc_allocateClassPair` proxy and the manual
188+
matrix pass — a regression in ORC class imaging would pass the suite
189+
while R03 silently un-flips. Add R03's macOS half to `RegressGuardTests`
190+
(an Xcode row like D08, asserting the three loaded rows in the render)
191+
in the next guard tranche.
169192

170193
## Out of scope
171194

examples/regress/VERIFICATION.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ not a nonzero command exit.
5252
| 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. |
5353
| 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). |
5454
| 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. |
55+
| R03 | Guard passes | 2026-07-21 (jit-bundle-resolution stage 1): both platforms render `Xcode resources loaded`, `Plist loaded`, `Core Data model loaded`, and the generated color — the agent's `bundleForClass:` hook redirects imageless JIT-class lookups to the target's framework wrapper (`CODESIGNING_FOLDER_PATH` plumbed via `BuildContext.resourceWrapperPath`; hook installed once per agent process, path refreshed per render). Inertness verified in the same pass: R02 (SwiftPM, wrapper nil) still renders Spanish and B04's real-image framework class keeps resolving its own wrapper. History: `Bundle(for:)` on a class compiled into the JIT resolved to the agent image while only `Generated*Symbols.swift` got the #151 wrapper rewrite (why the color always rendered); the 2026-07-14 pass recorded that partial state, 2026-07-15 re-verified it under compile capture, and the same-day iOS SIGILL regression (fat-build x86_64 capture) was fixed in #438. |
5656
| 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). |
5757
| 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. |
5858
| 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. |

previewsmcp/PreviewAgent/BUILD.bazel

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,20 @@
11
load("@rules_cc//cc:defs.bzl", "cc_binary")
2+
load("@rules_cc//cc:objc_library.bzl", "objc_library")
3+
load("@rules_swift//swift:swift.bzl", "swift_interop_hint")
4+
5+
swift_interop_hint(
6+
name = "BundleRedirect_swift_hint",
7+
module_name = "BundleRedirect",
8+
)
9+
10+
objc_library(
11+
name = "BundleRedirect",
12+
srcs = ["BundleRedirect.m"],
13+
hdrs = ["BundleRedirect.h"],
14+
aspect_hints = [":BundleRedirect_swift_hint"],
15+
visibility = ["//visibility:public"],
16+
alwayslink = True,
17+
)
218

319
cc_binary(
420
name = "PreviewAgent",
@@ -8,5 +24,8 @@ cc_binary(
824
"-fno-rtti",
925
],
1026
visibility = ["//visibility:public"],
11-
deps = ["//:llvm"],
27+
deps = [
28+
":BundleRedirect",
29+
"//:llvm",
30+
],
1231
)
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
#ifndef PREVIEWSMCP_BUNDLE_REDIRECT_H
2+
#define PREVIEWSMCP_BUNDLE_REDIRECT_H
3+
4+
/// Installs the agent-process +[NSBundle bundleForClass:] hook on first call
5+
/// and stores (or replaces) the target framework wrapper path the hook
6+
/// redirects to. Passing NULL clears the path, disabling redirection while
7+
/// leaving the hook installed. See docs/jit-bundle-resolution.md.
8+
void previewsmcp_set_resource_wrapper(const char *path);
9+
10+
#endif
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
#import "BundleRedirect.h"
2+
3+
#import <Foundation/Foundation.h>
4+
#import <objc/runtime.h>
5+
#import <os/lock.h>
6+
7+
// Keep in sync with the BundleRedirect enum in
8+
// previewsmcp/ios-host/agent/AgentApp.swift (the iOS agent's implementation
9+
// of the same contract).
10+
//
11+
// The JIT recompiles target sources into the agent process, so their classes
12+
// belong to no dyld image and +[NSBundle bundleForClass:] falls back to the
13+
// main bundle. The hook redirects exactly that combination — imageless class
14+
// resolved to the main bundle — to the target's on-disk framework wrapper.
15+
// It lives in the agent binary, never in JIT-generated code, because bridge
16+
// generations can be torn down while the swizzled IMP must stay valid for
17+
// the life of the process (docs/jit-bundle-resolution.md).
18+
19+
static os_unfair_lock gWrapperLock = OS_UNFAIR_LOCK_INIT;
20+
static NSString *gWrapperPath;
21+
22+
static NSString *currentWrapperPath(void) {
23+
os_unfair_lock_lock(&gWrapperLock);
24+
NSString *path = gWrapperPath;
25+
os_unfair_lock_unlock(&gWrapperLock);
26+
return path;
27+
}
28+
29+
static NSBundle *(*gOriginalBundleForClass)(id, SEL, Class);
30+
31+
static NSBundle *PreviewsMCPBundleForClass(id self, SEL _cmd, Class cls) {
32+
NSBundle *original = gOriginalBundleForClass(self, _cmd, cls);
33+
NSString *wrapper = currentWrapperPath();
34+
if (wrapper == nil || cls == Nil) {
35+
return original;
36+
}
37+
if (original != [NSBundle mainBundle] || class_getImageName(cls) != NULL) {
38+
return original;
39+
}
40+
NSBundle *redirected = [NSBundle bundleWithPath:wrapper];
41+
return redirected ?: original;
42+
}
43+
44+
__attribute__((used)) void previewsmcp_set_resource_wrapper(const char *path) {
45+
NSString *wrapper = path != NULL ? [NSString stringWithUTF8String:path] : nil;
46+
os_unfair_lock_lock(&gWrapperLock);
47+
gWrapperPath = wrapper;
48+
os_unfair_lock_unlock(&gWrapperLock);
49+
50+
static dispatch_once_t once;
51+
dispatch_once(&once, ^{
52+
Method method =
53+
class_getClassMethod([NSBundle class], @selector(bundleForClass:));
54+
gOriginalBundleForClass =
55+
(NSBundle * (*)(id, SEL, Class)) method_getImplementation(method);
56+
method_setImplementation(method, (IMP)PreviewsMCPBundleForClass);
57+
});
58+
}

previewsmcp/PreviewsCore/BridgeGenerator.swift

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,8 @@ public enum BridgeGenerator {
5757
stableModuleImport: String? = nil,
5858
renderWindow: JITRenderWindow? = nil,
5959
frameSidecarPath: String? = nil,
60-
setupErrorSidecarPath: String? = nil
60+
setupErrorSidecarPath: String? = nil,
61+
resourceWrapperPath: String? = nil
6162
) -> (source: String, literals: [LiteralEntry]) {
6263
// Transform source to replace literals with DesignTimeStore lookups
6364
let thunkResult = ThunkGenerator.transform(source: originalSource)
@@ -74,7 +75,10 @@ public enum BridgeGenerator {
7475
let hasSetup = isUsableSetup(module: setupModule, type: setupType)
7576
let setupImport = hasSetup ? "import \(setupModule!)\nimport PreviewsSetupKit\n" : ""
7677
let setUpEntry = hasSetup
77-
? setUpEntryPoint(setupType: setupType!, errorSidecarPath: setupErrorSidecarPath)
78+
? setUpEntryPoint(
79+
setupType: setupType!, errorSidecarPath: setupErrorSidecarPath,
80+
resourceWrapperPath: resourceWrapperPath
81+
)
7882
: ""
7983
let viewCode =
8084
hasSetup
@@ -93,24 +97,30 @@ public enum BridgeGenerator {
9397
case .macOS:
9498
return renderToFileEntryPoint(
9599
viewCode: viewCode, path: path, valuesPath: designTimeValuesPath,
96-
window: renderWindow, frameSidecarPath: frameSidecarPath
100+
window: renderWindow, frameSidecarPath: frameSidecarPath,
101+
resourceWrapperPath: resourceWrapperPath
97102
)
98103
case .iOS:
99-
return iosRenderEntryPoint(viewCode: viewCode, valuesPath: designTimeValuesPath)
104+
return iosRenderEntryPoint(
105+
viewCode: viewCode, valuesPath: designTimeValuesPath,
106+
resourceWrapperPath: resourceWrapperPath
107+
)
100108
}
101109
} ?? ""
102110
let bridgeCode = switch platform {
103111
case .macOS:
104112
"""
105113
import AppKit
106114
\(setupImport)
115+
\(resourceWrapperDeclaration)
107116
\(setUpEntry)
108117
\(renderEntry)
109118
"""
110119
case .iOS:
111120
"""
112121
import UIKit
113122
\(setupImport)
123+
\(resourceWrapperDeclaration)
114124
\(setUpEntry)
115125
\(renderEntry)
116126
"""
@@ -208,6 +218,25 @@ public enum BridgeGenerator {
208218
} ?? ""
209219
}
210220

221+
/// The set call runs before setup and before each view construction so
222+
/// target-code bundle lookups already resolve to the framework wrapper —
223+
/// and a nil wrapper still emits a clearing call, so an agent that
224+
/// survives a mid-session build-system identity change cannot keep a
225+
/// stale path. Resolved via `@_silgen_name` like `set_preview_vc`; the
226+
/// hook implementation lives in the agent binary, never here — bridge
227+
/// generations can be torn down while the swizzled IMP must outlive
228+
/// them.
229+
static let resourceWrapperDeclaration = """
230+
@_silgen_name("previewsmcp_set_resource_wrapper")
231+
func __previewsmcp_set_resource_wrapper(_ path: UnsafePointer<CChar>?)
232+
"""
233+
234+
private static func resourceWrapperCall(_ wrapperPath: String?) -> String {
235+
wrapperPath.map {
236+
"__previewsmcp_set_resource_wrapper(\"\(escapedForSwiftStringLiteral($0))\")"
237+
} ?? "__previewsmcp_set_resource_wrapper(nil)"
238+
}
239+
211240
/// Generate the `@_cdecl("renderPreviewToFile")` entry point (macOS, model-A JIT path).
212241
/// Builds the same preview view as `createPreviewView`, hosts it in a borderless
213242
/// `NSWindow` positioned off-screen via `NSHostingView` (AppKit-backed views like
@@ -222,7 +251,7 @@ public enum BridgeGenerator {
222251
/// AppKit state is shared across JITDylib generations; the entry's own globals are not.
223252
private static func renderToFileEntryPoint(
224253
viewCode: String, path: String, valuesPath: String?, window: JITRenderWindow?,
225-
frameSidecarPath: String?
254+
frameSidecarPath: String?, resourceWrapperPath: String?
226255
) -> String {
227256
let seed = designTimeSeed(valuesPath)
228257
let createWindow: String
@@ -308,6 +337,7 @@ public enum BridgeGenerator {
308337
public func renderPreviewToFile() -> Int32 {
309338
MainActor.assumeIsolated {
310339
\(seed)
340+
\(resourceWrapperCall(resourceWrapperPath))
311341
let view = \(viewCode)
312342
let identifier = NSUserInterfaceItemIdentifier("previewsmcp-preview")
313343
var isNewWindow = false
@@ -461,7 +491,9 @@ public enum BridgeGenerator {
461491
/// macOS entry it does not raster a PNG: the daemon captures the simulator screen via
462492
/// `simctl`, so the baked render path is unused here. Nullary and run over the agent's
463493
/// `runOnMain` surface; re-running it after a literal edit re-seeds DesignTimeStore.
464-
private static func iosRenderEntryPoint(viewCode: String, valuesPath: String?) -> String {
494+
private static func iosRenderEntryPoint(
495+
viewCode: String, valuesPath: String?, resourceWrapperPath: String?
496+
) -> String {
465497
let seed = designTimeSeed(valuesPath)
466498
return """
467499
@_silgen_name("previewsmcp_set_preview_vc")
@@ -471,6 +503,7 @@ public enum BridgeGenerator {
471503
public func renderPreviewToFile() -> Int32 {
472504
MainActor.assumeIsolated {
473505
\(seed)
506+
\(resourceWrapperCall(resourceWrapperPath))
474507
let view = \(viewCode)
475508
let hosting = UIHostingController(rootView: view)
476509
_previewsmcp_set_preview_vc(Unmanaged.passRetained(hosting).toOpaque())
@@ -515,7 +548,9 @@ public enum BridgeGenerator {
515548
/// skipping `wrap`), written to the error sidecar for the daemon to
516549
/// read, and reported as a nonzero status — never silently dropped
517550
/// (docs/phase-error-protocol.md, setup integrity; T01).
518-
private static func setUpEntryPoint(setupType: String, errorSidecarPath: String?) -> String {
551+
private static func setUpEntryPoint(
552+
setupType: String, errorSidecarPath: String?, resourceWrapperPath: String?
553+
) -> String {
519554
let recordError = errorSidecarPath.map { path -> String in
520555
"""
521556
try? ((error as? any LocalizedError)?.errorDescription ?? String(describing: error)).write(
@@ -525,6 +560,7 @@ public enum BridgeGenerator {
525560
return """
526561
@_cdecl("previewSetUp")
527562
public func previewSetUp() -> Int32 {
563+
\(resourceWrapperCall(resourceWrapperPath))
528564
let semaphore = DispatchSemaphore(value: 0)
529565
Task {
530566
do {

0 commit comments

Comments
 (0)