diff --git a/.github/workflows/cache-warm.yml b/.github/workflows/cache-warm.yml index 02748db..2181e1c 100644 --- a/.github/workflows/cache-warm.yml +++ b/.github/workflows/cache-warm.yml @@ -96,3 +96,32 @@ jobs: run: | swift build swift build --package-path examples/spm + + warm-jit-cache: + name: Warm jit-llvm cache + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode_26.3.app + + - name: Install cmake and ninja + run: brew install cmake ninja + + - name: Cache JIT LLVM build + uses: actions/cache@v4 + with: + path: | + third_party/llvm-build + third_party/llvm-build-rt + third_party/llvm-project + key: jit-llvm-${{ runner.os }}-${{ hashFiles('scripts/build-jit-llvm.sh') }} + + - name: Build JIT LLVM (skips on cache hit) + run: | + if [ -f third_party/llvm-build-rt/lib/darwin/liborc_rt_osx.a ]; then + echo "JIT LLVM already cached; nothing to build." + else + scripts/build-jit-llvm.sh + fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 60792f9..abeaa57 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -268,3 +268,71 @@ jobs: xcrun simctl list devices booted 2>/dev/null || true echo "--- orphan simctl/previewsmcp procs ---" ps -o pid,command -ax | grep -E "(simctl|previewsmcp)" | grep -v grep || true + + jit-tests: + needs: changes + if: needs.changes.outputs.src == 'true' + runs-on: macos-15 + # 90m absorbs a cache-miss libLLVM build from source (only on a + # build-jit-llvm.sh change / first run); the cache-warm.yml warm-jit-cache + # job keeps the cache warm so the normal path just restores and tests. + timeout-minutes: 90 + steps: + - uses: actions/checkout@v4 + + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode_26.3.app + + - name: Disable Spotlight + # Non-fatal: mdutil can exit 1 when a volume is mid-transition + # (kMDConfigSearchLevelTransitioning); it is only a build-speed hint. + run: sudo mdutil -a -i off || true + + - name: Install cmake and ninja + run: brew install cmake ninja + + - name: Cache JIT LLVM build + uses: actions/cache@v4 + with: + path: | + third_party/llvm-build + third_party/llvm-build-rt + third_party/llvm-project + key: jit-llvm-${{ runner.os }}-${{ hashFiles('scripts/build-jit-llvm.sh') }} + + - name: Cache SPM build + uses: actions/cache@v4 + with: + path: | + .build + examples/spm/.build + key: spm-${{ runner.os }}-jit-${{ hashFiles('Package.resolved') }} + restore-keys: | + spm-${{ runner.os }}-jit- + spm-${{ runner.os }}- + + - name: Build JIT LLVM if cache missed + run: | + if [ -f third_party/llvm-build-rt/lib/darwin/liborc_rt_osx.a ]; then + echo "JIT LLVM restored from cache." + else + echo "JIT LLVM cache miss; building from source (slow)." + scripts/build-jit-llvm.sh + fi + + - name: Build + run: | + swift build + swift build --package-path examples/spm + + - name: JIT tests + # The suite builds PreviewAgent and renders real previews in spawned + # agents; 20m covers the cold test-target + agent compile plus runs. + # --no-parallel: the JIT layer (shared in-process LLJIT + LLVM ORC) is + # not safe under Swift Testing's default parallel execution; concurrent + # compile/link/teardown trips LLVM assertions (assertions are on in our + # build) and SIGABRTs intermittently. Serial is stable. + timeout-minutes: 20 + env: + NSUnbufferedIO: "YES" + run: swift test --filter "PreviewsJITLinkTests" --no-parallel diff --git a/Package.swift b/Package.swift index 317a62d..fb3ee8a 100644 --- a/Package.swift +++ b/Package.swift @@ -11,6 +11,12 @@ let jitEnabled = FileManager.default.fileExists(atPath: llvmBuild) && FileManager.default.fileExists(atPath: orcRuntimeArchive) +// Composition-root wiring for the JIT structural-reload path. Only the executable +// references the gated PreviewsJITLink, behind one `#if PREVIEWSMCP_JIT`; the daemon +// logic depends solely on the JIT-free `StructuralReloader` protocol in PreviewsCore. +let jitCLIDependencies: [Target.Dependency] = jitEnabled ? ["PreviewsJITLink"] : [] +let jitCLISwiftSettings: [SwiftSetting] = jitEnabled ? [.define("PREVIEWSMCP_JIT")] : [] + var targets: [Target] = [ .target( name: "SimulatorBridge", @@ -53,7 +59,8 @@ var targets: [Target] = [ "PreviewsEngine", .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "MCP", package: "swift-sdk"), - ], + ] + jitCLIDependencies, + swiftSettings: jitCLISwiftSettings, plugins: [.plugin(name: "GenerateVersion")] ), .executableTarget( @@ -117,7 +124,7 @@ if jitEnabled { targets += [ .target( name: "PreviewsJITLink", - dependencies: ["PreviewsJITLinkCxx"], + dependencies: ["PreviewsJITLinkCxx", "PreviewsCore"], plugins: [.plugin(name: "BundleOrcRuntime")] ), .target( diff --git a/Sources/PreviewAgent/main.cpp b/Sources/PreviewAgent/main.cpp index a125c05..8b10981 100644 --- a/Sources/PreviewAgent/main.cpp +++ b/Sources/PreviewAgent/main.cpp @@ -1,4 +1,6 @@ +#include "llvm/ExecutionEngine/Orc/Shared/AllocationActions.h" #include "llvm/ExecutionEngine/Orc/Shared/ExecutorAddress.h" +#include "llvm/ExecutionEngine/Orc/Shared/MemoryFlags.h" #include "llvm/ExecutionEngine/Orc/Shared/TargetProcessControlTypes.h" #include "llvm/ExecutionEngine/Orc/Shared/WrapperFunctionUtils.h" #include "llvm/ExecutionEngine/Orc/TargetProcess/ExecutorSharedMemoryMapperService.h" @@ -8,11 +10,18 @@ #include "llvm/ExecutionEngine/Orc/TargetProcess/SimpleExecutorMemoryManager.h" #include "llvm/ExecutionEngine/Orc/TargetProcess/SimpleRemoteEPCServer.h" #include "llvm/Support/Error.h" +#include "llvm/Support/Memory.h" #include "llvm/Support/raw_ostream.h" #include +#include +#include #include +#include +#include #include +#include +#include using namespace llvm; using namespace llvm::orc; @@ -62,6 +71,110 @@ CWrapperFunctionResult previewsmcp_register_types(const char *ArgData, .release(); } +struct MainThreadCall { + int32_t (*Fn)(); + int32_t Result; +}; + +void invokeOnMain(void *Ctx) { + auto *Call = static_cast(Ctx); + Call->Result = Call->Fn(); +} + +CWrapperFunctionResult previewsmcp_run_on_main(const char *ArgData, + size_t ArgSize) { + return WrapperFunction::handle( + ArgData, ArgSize, + [](llvm::orc::ExecutorAddr FnAddr) -> int32_t { + MainThreadCall Call{FnAddr.toPtr(), 0}; + dispatch_sync_f(dispatch_get_main_queue(), &Call, invokeOnMain); + return Call.Result; + }) + .release(); +} + +std::mutex AnonMapperMutex; +std::map AnonReservations; + +CWrapperFunctionResult previewsmcp_anon_reserve(const char *ArgData, + size_t ArgSize) { + return WrapperFunction(uint64_t)>::handle( + ArgData, ArgSize, + [](uint64_t Size) -> Expected { + std::error_code EC; + auto MB = sys::Memory::allocateMappedMemory( + Size, nullptr, sys::Memory::MF_READ | sys::Memory::MF_WRITE, + EC); + if (EC) + return errorCodeToError(EC); + std::lock_guard Lock(AnonMapperMutex); + AnonReservations[MB.base()] = Size; + return llvm::orc::ExecutorAddr::fromPtr(MB.base()); + }) + .release(); +} + +CWrapperFunctionResult previewsmcp_anon_initialize(const char *ArgData, + size_t ArgSize) { + using namespace llvm::orc::tpctypes; + return WrapperFunction(SPSFinalizeRequest)>:: + handle(ArgData, ArgSize, + [](FinalizeRequest FR) -> Expected { + llvm::orc::ExecutorAddr Base(~0ULL); + for (auto &Seg : FR.Segments) + Base = std::min(Base, Seg.Addr); + for (auto &Seg : FR.Segments) { + char *Mem = Seg.Addr.toPtr(); + if (!Seg.Content.empty()) + memcpy(Mem, Seg.Content.data(), Seg.Content.size()); + memset(Mem + Seg.Content.size(), 0, + Seg.Size - Seg.Content.size()); + sys::MemoryBlock MB(Mem, Seg.Size); + if (auto EC = sys::Memory::protectMappedMemory( + MB, toSysMemoryProtectionFlags(Seg.RAG.Prot))) + return errorCodeToError(EC); + if ((Seg.RAG.Prot & MemProt::Exec) == MemProt::Exec) + sys::Memory::InvalidateInstructionCache(Mem, Seg.Size); + } + auto Dealloc = runFinalizeActions(FR.Actions); + if (!Dealloc) + return Dealloc.takeError(); + return Base; + }) + .release(); +} + +CWrapperFunctionResult previewsmcp_anon_deinitialize(const char *ArgData, + size_t ArgSize) { + return WrapperFunction)>::handle( + ArgData, ArgSize, + [](std::vector) -> Error { + return Error::success(); + }) + .release(); +} + +CWrapperFunctionResult previewsmcp_anon_release(const char *ArgData, + size_t ArgSize) { + return WrapperFunction)>::handle( + ArgData, ArgSize, + [](std::vector Bases) -> Error { + Error Err = Error::success(); + std::lock_guard Lock(AnonMapperMutex); + for (auto B : Bases) { + auto I = AnonReservations.find(B.toPtr()); + if (I == AnonReservations.end()) + continue; + sys::MemoryBlock MB(I->first, I->second); + if (auto EC = sys::Memory::releaseMappedMemory(MB)) + Err = joinErrors(std::move(Err), errorCodeToError(EC)); + AnonReservations.erase(I); + } + return Err; + }) + .release(); +} + CWrapperFunctionResult previewsmcp_write_pointers(const char *ArgData, size_t ArgSize) { using namespace llvm::orc::tpctypes; @@ -83,13 +196,14 @@ static void printErrorAndExit(Twine ErrMsg) { } int main(int argc, char *argv[]) { - ExitOnError ExitOnErr; - ExitOnErr.setBanner(std::string(argv[0]) + ": "); - dlopen("/usr/lib/swift/libswiftCore.dylib", RTLD_NOW | RTLD_GLOBAL); dlopen("/usr/lib/swift/libswift_Concurrency.dylib", RTLD_NOW | RTLD_GLOBAL); dlopen("/usr/lib/swift/libswiftFoundation.dylib", RTLD_NOW | RTLD_GLOBAL); dlopen("/usr/lib/swift/libswiftDispatch.dylib", RTLD_NOW | RTLD_GLOBAL); + dlopen("/System/Library/Frameworks/AppKit.framework/AppKit", + RTLD_NOW | RTLD_GLOBAL); + dlopen("/System/Library/Frameworks/SwiftUI.framework/SwiftUI", + RTLD_NOW | RTLD_GLOBAL); if (argc != 2) printErrorAndExit("expected exactly one argument"); @@ -107,31 +221,59 @@ int main(int argc, char *argv[]) { if (OutFDStr.getAsInteger(10, OutFD)) printErrorAndExit(OutFDStr + " is not a valid file descriptor"); - auto Server = - ExitOnErr(SimpleRemoteEPCServer::Create( - [](SimpleRemoteEPCServer::Setup &S) -> Error { - S.setDispatcher( - std::make_unique()); - S.bootstrapSymbols() = - SimpleRemoteEPCServer::defaultBootstrapSymbols(); - S.bootstrapSymbols()["__previewsmcp_register_conformances"] = - llvm::orc::ExecutorAddr::fromPtr( - &previewsmcp_register_conformances); - S.bootstrapSymbols()["__previewsmcp_register_types"] = - llvm::orc::ExecutorAddr::fromPtr(&previewsmcp_register_types); - S.bootstrapSymbols()["__previewsmcp_write_pointers"] = - llvm::orc::ExecutorAddr::fromPtr(&previewsmcp_write_pointers); - S.services().push_back( - std::make_unique()); - S.services().push_back( - std::make_unique()); - S.services().push_back( - std::make_unique< - rt_bootstrap::ExecutorSharedMemoryMapperService>()); - return Error::success(); - }, - InFD, OutFD)); - - ExitOnErr(Server->waitForDisconnect()); - return 0; + std::thread ServerThread([InFD, OutFD] { + ExitOnError ExitOnErr; + ExitOnErr.setBanner("PreviewAgent: "); + auto Server = ExitOnErr(SimpleRemoteEPCServer::Create< + FDSimpleRemoteEPCTransport>( + [](SimpleRemoteEPCServer::Setup &S) -> Error { + S.setDispatcher( + std::make_unique()); + S.bootstrapSymbols() = + SimpleRemoteEPCServer::defaultBootstrapSymbols(); + S.bootstrapSymbols()["__previewsmcp_register_conformances"] = + llvm::orc::ExecutorAddr::fromPtr( + &previewsmcp_register_conformances); + S.bootstrapSymbols()["__previewsmcp_register_types"] = + llvm::orc::ExecutorAddr::fromPtr(&previewsmcp_register_types); + S.bootstrapSymbols()["__previewsmcp_write_pointers"] = + llvm::orc::ExecutorAddr::fromPtr(&previewsmcp_write_pointers); + S.bootstrapSymbols()["__previewsmcp_run_on_main"] = + llvm::orc::ExecutorAddr::fromPtr(&previewsmcp_run_on_main); + S.bootstrapSymbols()["__previewsmcp_anon_reserve"] = + llvm::orc::ExecutorAddr::fromPtr(&previewsmcp_anon_reserve); + S.bootstrapSymbols()["__previewsmcp_anon_initialize"] = + llvm::orc::ExecutorAddr::fromPtr(&previewsmcp_anon_initialize); + S.bootstrapSymbols()["__previewsmcp_anon_deinitialize"] = + llvm::orc::ExecutorAddr::fromPtr(&previewsmcp_anon_deinitialize); + S.bootstrapSymbols()["__previewsmcp_anon_release"] = + llvm::orc::ExecutorAddr::fromPtr(&previewsmcp_anon_release); + S.services().push_back( + std::make_unique()); + S.services().push_back( + std::make_unique()); + S.services().push_back( + std::make_unique< + rt_bootstrap::ExecutorSharedMemoryMapperService>()); + return Error::success(); + }, + InFD, OutFD)); + ExitOnErr(Server->waitForDisconnect()); + std::_Exit(0); + }); + ServerThread.detach(); + + if (auto *Load = reinterpret_cast( + dlsym(RTLD_DEFAULT, "NSApplicationLoad"))) + Load(); + + auto *RunInMode = + reinterpret_cast( + dlsym(RTLD_DEFAULT, "CFRunLoopRunInMode")); + auto *DefaultMode = reinterpret_cast( + dlsym(RTLD_DEFAULT, "kCFRunLoopDefaultMode")); + if (!RunInMode || !DefaultMode) + printErrorAndExit("CoreFoundation run loop symbols not found"); + while (true) + RunInMode(*DefaultMode, 0.25, false); } diff --git a/Sources/PreviewsCLI/Handlers/PreviewStartHandler.swift b/Sources/PreviewsCLI/Handlers/PreviewStartHandler.swift index 808f6f3..b63eb4f 100644 --- a/Sources/PreviewsCLI/Handlers/PreviewStartHandler.swift +++ b/Sources/PreviewsCLI/Handlers/PreviewStartHandler.swift @@ -299,7 +299,7 @@ private func handleIOSPreviewStart( let sessionID = session.id let allPaths = [fileURL.path] + (buildContext?.sourceFiles?.map(\.path) ?? []) let iosState = ctx.iosState - let watcher = try? FileWatcher(paths: allPaths) { + let watcher = try? FileWatcher(paths: allPaths) { _ in Task { Log.info("MCP: iOS file change detected, reloading session \(sessionID)...") do { diff --git a/Sources/PreviewsCLI/PreviewsMCPApp.swift b/Sources/PreviewsCLI/PreviewsMCPApp.swift index d1f80ec..d796791 100644 --- a/Sources/PreviewsCLI/PreviewsMCPApp.swift +++ b/Sources/PreviewsCLI/PreviewsMCPApp.swift @@ -2,6 +2,10 @@ import AppKit import ArgumentParser import PreviewsMacOS +#if PREVIEWSMCP_JIT +import PreviewsJITLink +#endif + /// Target platform for CLI commands. enum CLIPlatform: String, ExpressibleByArgument, CaseIterable { case macos @@ -85,6 +89,9 @@ public struct PreviewsMCPApp { // other subcommand is now a daemon client. let app = NSApplication.shared let host = PreviewHost() + #if PREVIEWSMCP_JIT + host.structuralReloader = JITStructuralReloader() + #endif ServeCommand.sharedHost = host app.delegate = host diff --git a/Sources/PreviewsCore/BridgeGenerator.swift b/Sources/PreviewsCore/BridgeGenerator.swift index 19bb8e5..0cd7ad3 100644 --- a/Sources/PreviewsCore/BridgeGenerator.swift +++ b/Sources/PreviewsCore/BridgeGenerator.swift @@ -21,7 +21,10 @@ public enum BridgeGenerator { platform: PreviewPlatform = .macOS, traits: PreviewTraits = PreviewTraits(), setupModule: String? = nil, - setupType: String? = nil + setupType: String? = nil, + renderOutputPath: String? = nil, + designTimeValuesPath: String? = nil, + stableModuleImport: String? = nil ) -> (source: String, literals: [LiteralEntry]) { // Transform source to replace literals with DesignTimeStore lookups let thunkResult = ThunkGenerator.transform(source: originalSource) @@ -53,6 +56,11 @@ public enum BridgeGenerator { """ let bodyKindEntry = bodyKindEntryPoint(closureBody: transformedClosureBody) + let renderEntry = + renderOutputPath.map { + renderToFileEntryPoint( + viewCode: viewCode, path: $0, valuesPath: designTimeValuesPath) + } ?? "" let bridgeCode: String switch platform { case .macOS: @@ -68,6 +76,8 @@ public enum BridgeGenerator { } \(bodyKindEntry) + + \(renderEntry) """ case .iOS: bridgeCode = """ @@ -85,8 +95,10 @@ public enum BridgeGenerator { """ } + let stableImport = + stableModuleImport.map { "@testable import \($0)\n" } ?? "" let combined = """ - // --- DesignTimeStore --- + \(stableImport)// --- DesignTimeStore --- \(DesignTimeStoreSource.code) // --- Preview bridge helper (generated by PreviewsMCP) --- @@ -246,6 +258,48 @@ public enum BridgeGenerator { """ } + /// Generate the `@_cdecl("renderPreviewToFile")` entry point (macOS, model-A JIT path). + /// Builds the same preview view as `createPreviewView`, rasterizes it headless via + /// `ImageRenderer`, and writes a PNG to `path`. Nullary so it runs over the agent's + /// `runOnMain` surface; the path is baked in (the daemon recompiles per structural edit). + private static func renderToFileEntryPoint( + viewCode: String, path: String, valuesPath: String? + ) -> String { + let seed = + valuesPath.map { + """ + if let __dtData = try? Data(contentsOf: URL(fileURLWithPath: "\($0)")), + let __dtValues = try? JSONSerialization.jsonObject(with: __dtData) + as? [String: Any] + { + DesignTimeStore.shared.values = __dtValues + } + """ + } ?? "" + return """ + @_cdecl("renderPreviewToFile") + public func renderPreviewToFile() -> Int32 { + MainActor.assumeIsolated { + \(seed) + let view = \(viewCode) + let renderer = ImageRenderer(content: view) + renderer.scale = 1 + guard let cgImage = renderer.cgImage else { return Int32(-1) } + let rep = NSBitmapImageRep(cgImage: cgImage) + guard let data = rep.representation(using: .png, properties: [:]) else { + return Int32(-2) + } + do { + try data.write(to: URL(fileURLWithPath: "\(path)")) + } catch { + return Int32(-3) + } + return 0 + } + } + """ + } + /// Generate the `@_cdecl("previewSetUp")` entry point that bridges async setUp. private static func setUpEntryPoint(setupType: String) -> String { """ diff --git a/Sources/PreviewsCore/Compiler.swift b/Sources/PreviewsCore/Compiler.swift index 6ec9808..04105ea 100644 --- a/Sources/PreviewsCore/Compiler.swift +++ b/Sources/PreviewsCore/Compiler.swift @@ -178,6 +178,87 @@ public actor Compiler { return objectFile } + /// A prebuilt stable module: a `.swiftmodule` (consumed at compile time via `-I modulesDir`) + /// plus its linkable `.o` (added to the JIT alongside the per-edit editable unit). + public struct StableModule: Sendable { + public let moduleName: String + public let modulesDir: URL + public let objectPath: URL + } + + /// Build the stable half of the recompile-narrowing split: compile `sources` once into a + /// single whole-module `.o` plus a `-enable-testing` `.swiftmodule`. The editable unit then + /// compiles a single file against this prebuilt module (`-I modulesDir`, `@testable import`), + /// so an edit never re-parses the bulk. + public func emitStableModule( + sources: [String], + moduleName: String, + extraFlags: [String] = [] + ) async throws -> StableModule { + compilationCounter += 1 + let moduleDir = workDir.appendingPathComponent( + "stable-\(moduleName)-\(compilationCounter)", isDirectory: true) + try FileManager.default.createDirectory(at: moduleDir, withIntermediateDirectories: true) + + var sourceFiles: [URL] = [] + for (index, source) in sources.enumerated() { + let file = moduleDir.appendingPathComponent("bulk_\(index).swift") + try source.write(to: file, atomically: true, encoding: .utf8) + sourceFiles.append(file) + } + + return try await emitStableModule( + sourceFiles: sourceFiles, moduleName: moduleName, moduleDir: moduleDir, + extraFlags: extraFlags) + } + + /// File-based variant: compile existing project sources in place (their real paths) into + /// the stable module, without copying. Used by the Tier-2 recompile-narrowing split. + public func emitStableModule( + sourceFiles: [URL], + moduleName: String, + extraFlags: [String] = [] + ) async throws -> StableModule { + compilationCounter += 1 + let moduleDir = workDir.appendingPathComponent( + "stable-\(moduleName)-\(compilationCounter)", isDirectory: true) + try FileManager.default.createDirectory(at: moduleDir, withIntermediateDirectories: true) + return try await emitStableModule( + sourceFiles: sourceFiles, moduleName: moduleName, moduleDir: moduleDir, + extraFlags: extraFlags) + } + + private func emitStableModule( + sourceFiles: [URL], + moduleName: String, + moduleDir: URL, + extraFlags: [String] + ) async throws -> StableModule { + let objectFile = moduleDir.appendingPathComponent("\(moduleName).o") + let moduleFile = moduleDir.appendingPathComponent("\(moduleName).swiftmodule") + + var args: [String] = [ + swiftcPath, + "-wmo", + "-emit-object", + "-parse-as-library", + "-enable-testing", + "-target", targetTriple, + "-sdk", sdkPath, + "-module-name", moduleName, + "-Onone", + "-gnone", + "-module-cache-path", moduleCachePath.path, + "-emit-module-path", moduleFile.path, + ] + args += extraFlags + args += ["-o", objectFile.path] + args += sourceFiles.map(\.path) + + try await run(args) + return StableModule(moduleName: moduleName, modulesDir: moduleDir, objectPath: objectFile) + } + // MARK: - Private @discardableResult diff --git a/Sources/PreviewsCore/FileWatcher.swift b/Sources/PreviewsCore/FileWatcher.swift index 3fea771..0429bd4 100644 --- a/Sources/PreviewsCore/FileWatcher.swift +++ b/Sources/PreviewsCore/FileWatcher.swift @@ -17,14 +17,14 @@ public final class FileWatcher: @unchecked Sendable { public convenience init( path: String, - callback: @escaping @Sendable () -> Void + callback: @escaping @Sendable (String) -> Void ) throws { try self.init(paths: [path], callback: callback) } public init( paths: [String], - callback: @escaping @Sendable () -> Void + callback: @escaping @Sendable (String) -> Void ) throws { guard !paths.isEmpty else { throw FileWatcherError.cannotOpen(path: "") @@ -80,7 +80,7 @@ public final class FileWatcher: @unchecked Sendable { // callback regardless of how many watched paths it touched, // matching the "one reload per change burst" intent. for case let path as String in nsPaths where box.canonicalPaths.contains(path) { - box.callback() + box.callback(path) return } } @@ -142,9 +142,9 @@ public final class FileWatcher: @unchecked Sendable { /// deinit without racing in-flight callbacks against partial destruction. private final class CallbackBox: @unchecked Sendable { let canonicalPaths: Set - let callback: @Sendable () -> Void + let callback: @Sendable (String) -> Void - init(canonicalPaths: Set, callback: @escaping @Sendable () -> Void) { + init(canonicalPaths: Set, callback: @escaping @Sendable (String) -> Void) { self.canonicalPaths = canonicalPaths self.callback = callback } diff --git a/Sources/PreviewsCore/PreviewSession.swift b/Sources/PreviewsCore/PreviewSession.swift index 26edc46..20cc5f3 100644 --- a/Sources/PreviewsCore/PreviewSession.swift +++ b/Sources/PreviewsCore/PreviewSession.swift @@ -6,6 +6,48 @@ public struct CompileResult: Sendable { public let literals: [LiteralEntry] } +/// Result of compiling a preview for the JIT structural-reload path: a `.o` whose +/// `entrySymbol` renders the preview to a PNG at `imagePath` when run in the agent. +/// The render entry seeds `DesignTimeStore` from `valuesPath` (JSON) first, so a +/// literal-only edit can rewrite that file and re-render the same `.o` without +/// recompiling. `literals` are the design-time values baked at compile time. +public struct JITRenderBuild: Sendable { + public let objectPath: URL + public let imagePath: URL + public let valuesPath: URL + public let entrySymbol: String + public let literals: [LiteralEntry] + /// Prebuilt stable-module objects to link before `objectPath` (recompile-narrowing + /// split). Empty for the standalone path, where `objectPath` is self-contained. + public let supportObjectPaths: [URL] + /// Static-library archives (the target's `-L`/`-l` dependency archives) the agent must + /// link so the editable/stable objects' dependency symbols resolve. Empty for standalone. + public let archivePaths: [URL] + /// Binary dynamic libraries (the target's `-F`/`-framework` dependency frameworks) the + /// agent must `dlopen` so their symbols resolve. Empty for standalone. + public let dylibPaths: [URL] + + public init( + objectPath: URL, + imagePath: URL, + valuesPath: URL, + entrySymbol: String, + literals: [LiteralEntry], + supportObjectPaths: [URL] = [], + archivePaths: [URL] = [], + dylibPaths: [URL] = [] + ) { + self.objectPath = objectPath + self.imagePath = imagePath + self.valuesPath = valuesPath + self.entrySymbol = entrySymbol + self.literals = literals + self.supportObjectPaths = supportObjectPaths + self.archivePaths = archivePaths + self.dylibPaths = dylibPaths + } +} + /// Orchestrates the full preview pipeline: parse → generate bridge → compile → return dylib path. public actor PreviewSession { public nonisolated let id: String @@ -23,6 +65,8 @@ public actor PreviewSession { private var compilationResult: CompilationResult? private var lastOriginalSource: String? private var lastLiterals: [LiteralEntry]? + private var lastJITBuild: JITRenderBuild? + private var cachedStableModule: (key: [String: Date], module: Compiler.StableModule)? public enum State: Sendable { case idle @@ -147,6 +191,221 @@ public actor PreviewSession { } } + /// Compile the preview for the JIT structural-reload path. Generates a render bridge + /// with a baked PNG output path, compiles it to a `.o`, and returns the object plus the + /// image path the agent will write. + /// + /// In Tier-2 project mode the compile is split (recompile-narrowing): the hot preview + /// file is the editable unit, `@testable import`ing a stable module prebuilt from the + /// target's other sources, so an edit recompiles only the hot file. Standalone mode + /// compiles the self-contained combined source as one object. + public func compileObjectForJIT() async throws -> JITRenderBuild { + let source = try String(contentsOf: sourceFile, encoding: .utf8) + let previews = PreviewParser.parse(source: source) + + guard previewIndex >= 0, previewIndex < previews.count else { + throw PreviewSessionError.previewNotFound( + index: previewIndex, + available: previews.count + ) + } + let preview = previews[previewIndex] + + let stem = "previewsmcp-jit-\(id)-\(UUID().uuidString)" + let imagePath = FileManager.default.temporaryDirectory + .appendingPathComponent("\(stem).png") + let valuesPath = FileManager.default.temporaryDirectory + .appendingPathComponent("\(stem).json") + + let splitContext = buildContext.flatMap { ctx -> (BuildContext, [URL])? in + guard ctx.supportsTier2, let bulk = ctx.sourceFiles, !bulk.isEmpty else { return nil } + return (ctx, bulk) + } + + let generated = BridgeGenerator.generateCombinedSource( + originalSource: source, + closureBody: preview.closureBody, + previewIndex: previewIndex, + platform: platform, + traits: traits, + renderOutputPath: imagePath.path, + designTimeValuesPath: valuesPath.path, + stableModuleImport: splitContext?.0.moduleName + ) + + let objectPath: URL + var supportObjectPaths: [URL] = [] + var archivePaths: [URL] = [] + var dylibPaths: [URL] = [] + if let (ctx, bulk) = splitContext { + let stable = try await stableModule(for: bulk, context: ctx) + supportObjectPaths = [stable.objectPath] + archivePaths = Self.dependencyArchives(in: ctx.compilerFlags) + if let runtimeArchive = try await Toolchain.compilerRuntimeArchivePath() { + archivePaths.append(URL(fileURLWithPath: runtimeArchive)) + } + dylibPaths = Self.dependencyDylibs(in: ctx.compilerFlags) + objectPath = try await compiler.compileObject( + source: generated.source, + moduleName: "PreviewEdit_\(ctx.moduleName)_\(Self.uniqueModuleToken())", + extraFlags: ["-I", stable.modulesDir.path] + ctx.compilerFlags + ) + } else { + objectPath = try await compiler.compileObject( + source: generated.source, + moduleName: "\(Self.moduleName(for: sourceFile))_\(Self.uniqueModuleToken())" + ) + } + try Self.writeDesignTimeValues(generated.literals, to: valuesPath) + + lastOriginalSource = source + lastLiterals = generated.literals + + let build = JITRenderBuild( + objectPath: objectPath, + imagePath: imagePath, + valuesPath: valuesPath, + entrySymbol: "renderPreviewToFile", + literals: generated.literals, + supportObjectPaths: supportObjectPaths, + archivePaths: archivePaths, + dylibPaths: dylibPaths + ) + lastJITBuild = build + return build + } + + /// Resolve the `-L ` / `-l` pairs in `flags` to `/lib.a` archive + /// paths that exist on disk. These are the target's dependency archives the JIT agent + /// must link so the editable/stable objects' cross-target symbols resolve (G3). + static func dependencyArchives(in flags: [String]) -> [URL] { + var searchDirs: [URL] = [] + var names: [String] = [] + var index = 0 + while index < flags.count { + let flag = flags[index] + if flag == "-L", index + 1 < flags.count { + searchDirs.append(URL(fileURLWithPath: flags[index + 1])) + index += 2 + } else if flag.hasPrefix("-l") && flag.count > 2 { + names.append(String(flag.dropFirst(2))) + index += 1 + } else { + index += 1 + } + } + var archives: [URL] = [] + for name in names { + for dir in searchDirs { + let candidate = dir.appendingPathComponent("lib\(name).a") + if FileManager.default.fileExists(atPath: candidate.path) { + archives.append(candidate) + break + } + } + } + return archives + } + + /// Resolve the `-F ` / `-framework ` pairs in `flags` to the framework binary + /// at `/.framework/`. These are the target's binary-framework deps the + /// JIT agent must `dlopen` so their symbols resolve (G3-b). + static func dependencyDylibs(in flags: [String]) -> [URL] { + var searchDirs: [URL] = [] + var names: [String] = [] + var index = 0 + while index < flags.count { + let flag = flags[index] + if flag == "-F", index + 1 < flags.count { + searchDirs.append(URL(fileURLWithPath: flags[index + 1])) + index += 2 + } else if flag == "-framework", index + 1 < flags.count { + names.append(flags[index + 1]) + index += 2 + } else { + index += 1 + } + } + var dylibs: [URL] = [] + for name in names { + for dir in searchDirs { + let candidate = dir.appendingPathComponent("\(name).framework/\(name)") + if FileManager.default.fileExists(atPath: candidate.path) { + dylibs.append(candidate) + break + } + } + } + return dylibs + } + + /// Return the stable module for `bulk`, reusing the cached one when no bulk file has + /// changed (the common case: repeated edits to the hot preview file). Rebuilds only when + /// a bulk file's modification date changes, so the per-edit compile stays narrow. + private func stableModule( + for bulk: [URL], context ctx: BuildContext + ) async throws -> Compiler.StableModule { + let key = Self.bulkKey(bulk) + if let cached = cachedStableModule, cached.key == key { + return cached.module + } + let module = try await compiler.emitStableModule( + sourceFiles: bulk, + moduleName: ctx.moduleName, + extraFlags: ctx.compilerFlags + ) + cachedStableModule = (key, module) + return module + } + + private static func bulkKey(_ bulk: [URL]) -> [String: Date] { + var key: [String: Date] = [:] + for file in bulk { + let date = + (try? FileManager.default.attributesOfItem(atPath: file.path)[.modificationDate] + as? Date) ?? .distantPast + key[file.path] = date + } + return key + } + + /// Apply a literal-only edit to the agent-backed render: rewrite the design-time + /// values JSON for the last JIT build so re-running its render entry reflects the + /// new values, without recompiling. Returns the build to re-render, or nil if the + /// session has no JIT build yet. + public func applyLiteralValuesForJIT( + _ changes: [(id: String, newValue: LiteralValue)] + ) throws -> JITRenderBuild? { + guard let build = lastJITBuild else { return nil } + var dict = + (try? JSONSerialization.jsonObject(with: Data(contentsOf: build.valuesPath)) + as? [String: Any]) ?? [:] + for change in changes { + dict[change.id] = Self.anyValue(change.newValue) + } + try JSONSerialization.data(withJSONObject: dict).write(to: build.valuesPath) + return build + } + + /// Serialize design-time literal values to the JSON the render bridge seeds from. + static func writeDesignTimeValues(_ literals: [LiteralEntry], to path: URL) throws { + var dict: [String: Any] = [:] + for entry in literals { + dict[entry.id] = anyValue(entry.value) + } + let data = try JSONSerialization.data(withJSONObject: dict) + try data.write(to: path) + } + + private static func anyValue(_ value: LiteralValue) -> Any { + switch value { + case .string(let s): return s + case .integer(let n): return n + case .float(let d): return d + case .boolean(let b): return b + } + } + /// Attempt a fast literal-only update. Returns changed literal IDs and new values, /// or nil if a structural recompile is needed. /// Returns nil for Tier 1 project mode (bridge-only, no thunks). @@ -200,6 +459,13 @@ public actor PreviewSession { return try await compile() } + /// A fresh, globally-unique module-name suffix (a valid Swift identifier) so the editable + /// unit's classes (e.g. `DesignTimeStore`) mangle distinctly on every compile. Without it, + /// the capped-persistent agent re-registers the same ObjC class across generations. + private static func uniqueModuleToken() -> String { + "g" + UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(12) + } + private static func moduleName(for file: URL) -> String { let stem = file.deletingPathExtension().lastPathComponent let hash = String(stableHash(file.path), radix: 16).prefix(6) diff --git a/Sources/PreviewsCore/StructuralReloader.swift b/Sources/PreviewsCore/StructuralReloader.swift new file mode 100644 index 0000000..16933ad --- /dev/null +++ b/Sources/PreviewsCore/StructuralReloader.swift @@ -0,0 +1,19 @@ +import Foundation + +/// Reloads a structurally-edited preview by linking a freshly compiled object in an +/// isolated process and running its render entry on that process's main thread. The +/// object's render entry writes the preview image to the path baked in at compile time +/// (file transport). +/// +/// Defined here, JIT-free, so the daemon depends on this abstraction rather than the +/// gated JIT target: `PreviewsJITLink` provides the implementation and the executable +/// injects it only when the JIT build is present. The protocol is agnostic to whether +/// the implementation respawns a process per edit or reuses a capped-persistent one. +public protocol StructuralReloader: Sendable { + /// Render `build`'s entry, first linking the target's dependencies: its `dylibPaths` + /// (binary frameworks the agent `dlopen`s), `archivePaths` (static dependency archives), + /// and `supportObjectPaths` (the prebuilt stable-module objects from the recompile- + /// narrowing split). All are empty for the standalone path, where `objectPath` is + /// self-contained. + func render(_ build: JITRenderBuild) async throws +} diff --git a/Sources/PreviewsCore/Toolchain.swift b/Sources/PreviewsCore/Toolchain.swift index f9b81a7..4bdc8e7 100644 --- a/Sources/PreviewsCore/Toolchain.swift +++ b/Sources/PreviewsCore/Toolchain.swift @@ -51,6 +51,17 @@ public enum Toolchain { } } + /// Absolute path to the compiler-rt builtins archive (`libclang_rt.osx.a`), or nil if not + /// found. The JIT agent links this so compiler builtins like `__isPlatformVersionAtLeast` + /// (emitted by `#available`) resolve at JIT-link time (#191 G3-c). + public static func compilerRuntimeArchivePath() async throws -> String? { + let dir = try await cached(key: "clang-runtime-dir") { + try await xcrun(["clang", "-print-runtime-dir"], discardStderr: true) + } + let archive = (dir as NSString).appendingPathComponent("libclang_rt.osx.a") + return FileManager.default.fileExists(atPath: archive) ? archive : nil + } + /// Absolute path to the active xcodebuild binary, or nil if not installed. public static func xcodebuildPath() async throws -> String? { // Distinct cache key so failures stay observable; we cache success only. diff --git a/Sources/PreviewsEngine/MacOSPreviewHandle.swift b/Sources/PreviewsEngine/MacOSPreviewHandle.swift index 610b2a8..afe0f39 100644 --- a/Sources/PreviewsEngine/MacOSPreviewHandle.swift +++ b/Sources/PreviewsEngine/MacOSPreviewHandle.swift @@ -63,6 +63,9 @@ public actor MacOSPreviewHandle: PreviewSessionHandle { let format: Snapshot.ImageFormat = quality >= 1.0 ? .png : .jpeg(quality: quality) let sessionID = id return try await MainActor.run { + if let imagePath = host.agentSnapshotPath(for: sessionID) { + return try Snapshot.encode(imageAt: imagePath, format: format) + } guard let window = host.window(for: sessionID) else { throw SnapshotError.captureFailed } diff --git a/Sources/PreviewsJITLink/JITStructuralReloader.swift b/Sources/PreviewsJITLink/JITStructuralReloader.swift new file mode 100644 index 0000000..effb368 --- /dev/null +++ b/Sources/PreviewsJITLink/JITStructuralReloader.swift @@ -0,0 +1,75 @@ +import Foundation +import PreviewsCore + +/// `StructuralReloader` backed by the remote JIT agent, capped-persistent: one agent serves +/// many edits, each linked into a fresh `JITDylib` (`newGeneration`), and the agent respawns +/// every `generationCap` edits. Respawn bounds the unreclaimable `__swift5_*` metadata that +/// each generation leaks (it cannot be deregistered). The render entry runs on the agent's +/// main thread and writes the preview PNG to the path baked into the object at compile time. +public actor JITStructuralReloader: StructuralReloader { + private let generationCap: Int + private var session: JITSession? + private var generation = 0 + private var lastObjectPath: URL? + + public init(generationCap: Int = 100) { + self.generationCap = generationCap + } + + public func render(_ build: JITRenderBuild) async throws { + // Literal re-render: the same object is already linked in the live generation, so just + // re-run its entry. It re-seeds DesignTimeStore from the (rewritten) values JSON, with + // no new JITDylib and no re-link (which would re-register the object's classes). + if let session, build.objectPath == lastObjectPath { + try Self.run(session, build.entrySymbol) + return + } + + let session = try nextSession() + for dylib in build.dylibPaths { + try session.addDylib(path: dylib.path) + } + for archive in build.archivePaths { + try session.addArchive(path: archive.path) + } + for support in build.supportObjectPaths { + try session.addObject(path: support.path) + } + try session.addObject(path: build.objectPath.path) + lastObjectPath = build.objectPath + try Self.run(session, build.entrySymbol) + } + + private static func run(_ session: JITSession, _ entrySymbol: String) throws { + let status = try session.runOnMain(symbol: entrySymbol) + guard status == 0 else { + throw JITReloadError.renderFailed(status: status) + } + } + + /// The session to link this edit into: a fresh `JITDylib` on the live agent while under + /// the cap, otherwise a freshly respawned agent (replacing the old one, whose `deinit` + /// kills its process). The first edit and each post-cap edit start a new agent. + private func nextSession() throws -> JITSession { + if let session, generation < generationCap { + generation += 1 + try session.newGeneration() + return session + } + let fresh = try JITSession(remoteAgentPath: JITSession.bundledAgentPath()) + session = fresh + generation = 1 + return fresh + } +} + +public enum JITReloadError: Error, CustomStringConvertible { + case renderFailed(status: Int32) + + public var description: String { + switch self { + case .renderFailed(let status): + return "JIT render entry returned non-zero status \(status)" + } + } +} diff --git a/Sources/PreviewsJITLink/PreviewsJITLink.swift b/Sources/PreviewsJITLink/PreviewsJITLink.swift index 8377a99..1eea593 100644 --- a/Sources/PreviewsJITLink/PreviewsJITLink.swift +++ b/Sources/PreviewsJITLink/PreviewsJITLink.swift @@ -57,6 +57,14 @@ public final class JITSession { return result } + public func runOnMain(symbol: String) throws -> Int32 { + var result: Int32 = 0 + if let error = previewsmcp_jit_session_run_on_main(handle, symbol, &result) { + throw JITLinkError.failed(error.string()) + } + return result + } + public func writePointer(at address: UInt64, value: UInt64) throws { if let error = previewsmcp_jit_session_write_pointer(handle, address, value) { throw JITLinkError.failed(error.string()) @@ -69,6 +77,27 @@ public final class JITSession { } } + public func addArchive(path: String) throws { + if let error = previewsmcp_jit_session_add_archive(handle, path) { + throw JITLinkError.failed(error.string()) + } + } + + public func addDylib(path: String) throws { + if let error = previewsmcp_jit_session_add_dylib(handle, path) { + throw JITLinkError.failed(error.string()) + } + } + + /// Start a fresh generation: subsequent `addObject`/`addArchive`/`addDylib`/`runOnMain` + /// target a new `JITDylib` on the same agent, and the next run re-runs `LLJIT::initialize` + /// on it (registers `__swift5_*`). Lets one agent serve many edits (capped-persistent). + public func newGeneration() throws { + if let error = previewsmcp_jit_session_new_generation(handle) { + throw JITLinkError.failed(error.string()) + } + } + public func address(of symbol: String) throws -> UInt64 { var address: UInt64 = 0 if let error = previewsmcp_jit_session_lookup(handle, symbol, &address) { diff --git a/Sources/PreviewsJITLinkCxx/PreviewsJITLinkCxx.cpp b/Sources/PreviewsJITLinkCxx/PreviewsJITLinkCxx.cpp index e77165f..378d95e 100644 --- a/Sources/PreviewsJITLinkCxx/PreviewsJITLinkCxx.cpp +++ b/Sources/PreviewsJITLinkCxx/PreviewsJITLinkCxx.cpp @@ -10,18 +10,23 @@ #include #include #include +#include #include +#include #include #include #include #include #include +#include #include #include +#include #include #include #include #include +#include #include #include #include @@ -50,12 +55,157 @@ slabLinkingLayer(llvm::orc::ExecutionSession &es, const llvm::Triple &) { return layer; } -llvm::Expected> -makeJIT(const char *orc_rt_path) { +void initNativeTargetOnce() { static std::once_flag once; std::call_once(once, [] { LLVMInitializeNativeTarget(); LLVMInitializeNativeAsmPrinter(); + }); +} + +struct AnonMapperSymbols { + llvm::orc::ExecutorAddr Reserve; + llvm::orc::ExecutorAddr Initialize; + llvm::orc::ExecutorAddr Deinitialize; + llvm::orc::ExecutorAddr Release; +}; + +class PreviewsAnonymousMapper : public llvm::orc::MemoryMapper { +public: + PreviewsAnonymousMapper(llvm::orc::SimpleRemoteEPC &epc, + AnonMapperSymbols sas) + : epc(epc), sas(sas) {} + + unsigned int getPageSize() override { return epc.getPageSize(); } + + void reserve(size_t numBytes, OnReservedFunction onReserved) override { + epc.callSPSWrapperAsync(uint64_t)>( + sas.Reserve, + [this, numBytes, onReserved = std::move(onReserved)]( + llvm::Error serErr, + llvm::Expected result) mutable { + if (serErr) { + llvm::consumeError(result.takeError()); + return onReserved(std::move(serErr)); + } + if (!result) { + return onReserved(result.takeError()); + } + auto base = *result; + auto *buf = static_cast(malloc(numBytes)); + { + std::lock_guard lock(mutex); + reservations[base] = {buf, numBytes}; + } + onReserved(llvm::orc::ExecutorAddrRange(base, base + numBytes)); + }, + static_cast(numBytes)); + } + + char *prepare(llvm::orc::ExecutorAddr addr, size_t contentSize) override { + std::lock_guard lock(mutex); + auto r = reservations.upper_bound(addr); + --r; + return r->second.workingBuf + (addr - r->first); + } + + void initialize(AllocInfo &ai, OnInitializedFunction onInitialized) override { + llvm::orc::tpctypes::FinalizeRequest fr; + fr.Actions = std::move(ai.Actions); + char *base; + { + std::lock_guard lock(mutex); + auto r = reservations.upper_bound(ai.MappingBase); + --r; + base = r->second.workingBuf + (ai.MappingBase - r->first); + } + fr.Segments.reserve(ai.Segments.size()); + for (auto &seg : ai.Segments) { + char *segBuf = base + seg.Offset; + memset(segBuf + seg.ContentSize, 0, seg.ZeroFillSize); + llvm::orc::tpctypes::SegFinalizeRequest sr; + sr.RAG = {seg.AG.getMemProt(), + seg.AG.getMemLifetime() == llvm::orc::MemLifetime::Finalize}; + sr.Addr = ai.MappingBase + seg.Offset; + sr.Size = seg.ContentSize + seg.ZeroFillSize; + sr.Content = llvm::ArrayRef(segBuf, seg.ContentSize); + fr.Segments.push_back(sr); + } + epc.callSPSWrapperAsync< + llvm::orc::shared::SPSExpected( + llvm::orc::shared::SPSFinalizeRequest)>( + sas.Initialize, + [onInitialized = std::move(onInitialized)]( + llvm::Error serErr, + llvm::Expected result) mutable { + if (serErr) { + llvm::consumeError(result.takeError()); + return onInitialized(std::move(serErr)); + } + onInitialized(std::move(result)); + }, + std::move(fr)); + } + + void deinitialize(llvm::ArrayRef allocs, + OnDeinitializedFunction onDeinit) override { + epc.callSPSWrapperAsync)>( + sas.Deinitialize, + [onDeinit = std::move(onDeinit)](llvm::Error serErr, + llvm::Error result) mutable { + if (serErr) { + llvm::consumeError(std::move(result)); + return onDeinit(std::move(serErr)); + } + onDeinit(std::move(result)); + }, + allocs); + } + + void release(llvm::ArrayRef bases, + OnReleasedFunction onReleased) override { + { + std::lock_guard lock(mutex); + for (auto b : bases) { + auto i = reservations.find(b); + if (i != reservations.end()) { + free(i->second.workingBuf); + reservations.erase(i); + } + } + } + epc.callSPSWrapperAsync)>( + sas.Release, + [onReleased = std::move(onReleased)](llvm::Error serErr, + llvm::Error result) mutable { + if (serErr) { + llvm::consumeError(std::move(result)); + return onReleased(std::move(serErr)); + } + onReleased(std::move(result)); + }, + bases); + } + +private: + struct Reservation { + char *workingBuf; + size_t size; + }; + llvm::orc::SimpleRemoteEPC &epc; + AnonMapperSymbols sas; + std::mutex mutex; + std::map reservations; +}; + +llvm::Expected> +makeJIT(const char *orc_rt_path) { + initNativeTargetOnce(); + static std::once_flag debugOnce; + std::call_once(debugOnce, [] { if (getenv("PREVIEWSMCP_JIT_DEBUG")) { static const char *types[] = {"jitlink", "orc"}; llvm::DebugFlag = true; @@ -115,6 +265,7 @@ struct previewsmcp_jit_session { std::unique_ptr ownedJit; llvm::orc::LLJIT *jit = nullptr; llvm::orc::JITDylib *jd = nullptr; + llvm::orc::ExecutorAddr runOnMain; pid_t agentPid = 0; bool initialized = false; }; @@ -123,6 +274,8 @@ namespace { llvm::Expected lookupInitialized(previewsmcp_jit_session *session, const char *symbol_name) { if (!session->initialized) { + static std::mutex initMutex; + std::lock_guard lock(initMutex); if (auto err = session->jit->initialize(*session->jd)) { return std::move(err); } @@ -170,11 +323,7 @@ const char * previewsmcp_jit_remote_session_create(previewsmcp_jit_session **out_session, const char *agent_path, const char *orc_rt_path) { - static std::once_flag targetOnce; - std::call_once(targetOnce, [] { - LLVMInitializeNativeTarget(); - LLVMInitializeNativeAsmPrinter(); - }); + initNativeTargetOnce(); int sv[2]; if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) != 0) { @@ -226,10 +375,22 @@ previewsmcp_jit_remote_session_create(previewsmcp_jit_session **out_session, return toCStr(epc.takeError()); } - llvm::orc::ExecutorAddr registerConformances, registerTypes; + llvm::orc::ExecutorAddr registerConformances, registerTypes, runOnMain; if (auto err = (*epc)->getBootstrapSymbols( {{registerConformances, "__previewsmcp_register_conformances"}, - {registerTypes, "__previewsmcp_register_types"}})) { + {registerTypes, "__previewsmcp_register_types"}, + {runOnMain, "__previewsmcp_run_on_main"}})) { + llvm::consumeError((*epc)->disconnect()); + killAgent(pid); + return toCStr(std::move(err)); + } + + AnonMapperSymbols anonSyms; + if (auto err = (*epc)->getBootstrapSymbols( + {{anonSyms.Reserve, "__previewsmcp_anon_reserve"}, + {anonSyms.Initialize, "__previewsmcp_anon_initialize"}, + {anonSyms.Deinitialize, "__previewsmcp_anon_deinitialize"}, + {anonSyms.Release, "__previewsmcp_anon_release"}})) { llvm::consumeError((*epc)->disconnect()); killAgent(pid); return toCStr(std::move(err)); @@ -240,11 +401,17 @@ previewsmcp_jit_remote_session_create(previewsmcp_jit_session **out_session, .setExecutorProcessControl(std::move(*epc)) .setPlatformSetUp(llvm::orc::ExecutorNativePlatform(orc_rt_path)) .setObjectLinkingLayerCreator( - [registerConformances, registerTypes]( - llvm::orc::ExecutionSession &es, const llvm::Triple &) + [registerConformances, registerTypes, + anonSyms](llvm::orc::ExecutionSession &es, const llvm::Triple &) -> llvm::Expected> { - auto layer = - std::make_unique(es); + auto &srepc = static_cast( + es.getExecutorProcessControl()); + auto memMgr = + std::make_unique( + kSlabSize, std::make_unique( + srepc, anonSyms)); + auto layer = std::make_unique( + es, std::move(memMgr)); layer->addPlugin( std::make_shared( registerConformances, registerTypes)); @@ -259,6 +426,7 @@ previewsmcp_jit_remote_session_create(previewsmcp_jit_session **out_session, auto *session = new previewsmcp_jit_session{}; session->ownedJit = std::move(*jit); session->jit = session->ownedJit.get(); + session->runOnMain = runOnMain; session->agentPid = pid; static std::atomic counter{0}; auto jd = session->jit->createJITDylib("remote." + @@ -290,6 +458,28 @@ const char *previewsmcp_jit_session_run_main(previewsmcp_jit_session *session, return nullptr; } +const char * +previewsmcp_jit_session_run_on_main(previewsmcp_jit_session *session, + const char *symbol_name, + int32_t *out_result) { + if (!session->runOnMain) { + return strdup("run_on_main requires a remote session"); + } + auto sym = lookupInitialized(session, symbol_name); + if (!sym) { + return toCStr(sym.takeError()); + } + auto &epc = session->jit->getExecutionSession().getExecutorProcessControl(); + int32_t result = 0; + if (auto err = + epc.callSPSWrapper( + session->runOnMain, result, *sym)) { + return toCStr(std::move(err)); + } + *out_result = result; + return nullptr; +} + const char * previewsmcp_jit_session_write_pointer(previewsmcp_jit_session *session, uint64_t address, uint64_t value) { @@ -311,6 +501,42 @@ const char *previewsmcp_jit_session_add_object(previewsmcp_jit_session *session, return toCStr(session->jit->addObjectFile(*session->jd, std::move(*buf))); } +const char * +previewsmcp_jit_session_add_archive(previewsmcp_jit_session *session, + const char *archive_path) { + auto generator = llvm::orc::StaticLibraryDefinitionGenerator::Load( + session->jit->getObjLinkingLayer(), archive_path); + if (!generator) { + return toCStr(generator.takeError()); + } + session->jd->addGenerator(std::move(*generator)); + return nullptr; +} + +const char *previewsmcp_jit_session_add_dylib(previewsmcp_jit_session *session, + const char *dylib_path) { + auto generator = llvm::orc::EPCDynamicLibrarySearchGenerator::Load( + session->jit->getExecutionSession(), dylib_path); + if (!generator) { + return toCStr(generator.takeError()); + } + session->jd->addGenerator(std::move(*generator)); + return nullptr; +} + +const char * +previewsmcp_jit_session_new_generation(previewsmcp_jit_session *session) { + static std::atomic counter{0}; + auto jd = session->jit->createJITDylib("generation." + + std::to_string(counter.fetch_add(1))); + if (!jd) { + return toCStr(jd.takeError()); + } + session->jd = &*jd; + session->initialized = false; + return nullptr; +} + const char *previewsmcp_jit_session_lookup(previewsmcp_jit_session *session, const char *symbol_name, uint64_t *out_address) { diff --git a/Sources/PreviewsJITLinkCxx/include/PreviewsJITLinkCxx.h b/Sources/PreviewsJITLinkCxx/include/PreviewsJITLinkCxx.h index 7b067b3..17d5817 100644 --- a/Sources/PreviewsJITLinkCxx/include/PreviewsJITLinkCxx.h +++ b/Sources/PreviewsJITLinkCxx/include/PreviewsJITLinkCxx.h @@ -29,12 +29,25 @@ const char *_Nullable previewsmcp_jit_session_run_main( previewsmcp_jit_session *session, const char *symbol_name, int32_t *out_result); +const char *_Nullable previewsmcp_jit_session_run_on_main( + previewsmcp_jit_session *session, const char *symbol_name, + int32_t *out_result); + const char *_Nullable previewsmcp_jit_session_write_pointer( previewsmcp_jit_session *session, uint64_t address, uint64_t value); const char *_Nullable previewsmcp_jit_session_add_object( previewsmcp_jit_session *session, const char *object_path); +const char *_Nullable previewsmcp_jit_session_add_archive( + previewsmcp_jit_session *session, const char *archive_path); + +const char *_Nullable previewsmcp_jit_session_add_dylib( + previewsmcp_jit_session *session, const char *dylib_path); + +const char *_Nullable previewsmcp_jit_session_new_generation( + previewsmcp_jit_session *session); + const char *_Nullable previewsmcp_jit_session_lookup( previewsmcp_jit_session *session, const char *symbol_name, uint64_t *out_address); diff --git a/Sources/PreviewsMacOS/HostApp.swift b/Sources/PreviewsMacOS/HostApp.swift index 52dd61e..2e91495 100644 --- a/Sources/PreviewsMacOS/HostApp.swift +++ b/Sources/PreviewsMacOS/HostApp.swift @@ -16,6 +16,10 @@ public class PreviewHost: NSObject, NSApplicationDelegate { private var windows: [String: NSWindow] = [:] private var loaders: [String: DylibLoader] = [:] private var sessions: [String: PreviewSession] = [:] + /// Latest agent-rendered PNG per session. Set once a session goes through the + /// JIT structural-reload path; `preview_snapshot` serves this instead of the + /// in-daemon window for those sessions. + private var agentImagePaths: [String: URL] = [:] // Keep old loaders and views alive so their dylib types remain valid. private var retainedLoaders: [DylibLoader] = [] private var retainedViews: [NSView] = [] @@ -28,6 +32,11 @@ public class PreviewHost: NSObject, NSApplicationDelegate { /// Callback invoked after NSApplication finishes launching. public var onLaunch: (@MainActor () -> Void)? + /// Structural-reload strategy for the JIT path. Injected at the composition + /// root, non-nil only in JIT builds. When set, structural edits render in the + /// agent instead of rebuilding an in-daemon dylib. + public var structuralReloader: (any StructuralReloader)? + /// Async sink that publishes a snapshot of macOS sessions to the /// cross-process registry. Set once by the engine layer at host /// construction; not reassigned per MCP connection. @@ -168,7 +177,7 @@ public class PreviewHost: NSObject, NSApplicationDelegate { let fileURL = URL(fileURLWithPath: filePath) let allPaths = [filePath] + additionalPaths fileWatchers[sessionID]?.stop() - fileWatchers[sessionID] = try? FileWatcher(paths: allPaths) { [weak self] in + fileWatchers[sessionID] = try? FileWatcher(paths: allPaths) { [weak self] _ in Task { guard let self else { return } @@ -180,12 +189,27 @@ public class PreviewHost: NSObject, NSApplicationDelegate { return } - // Fast path: try literal-only update + // Fast path: try literal-only update. An agent-backed session re-renders + // in the agent (rewrite the design-time values JSON, re-run the same + // object — no recompile); otherwise the in-daemon DesignTimeStore path. + let agentBacked = await MainActor.run { + self.agentSnapshotPath(for: sessionID) != nil + } if let currentSession = await MainActor.run(body: { self.sessions[sessionID] }), let changes = await currentSession.tryLiteralUpdate(newSource: newSource), !changes.isEmpty { fputs("Literal-only change: \(changes.count) value(s)\n", stderr) + if agentBacked { + do { + try await self.jitLiteralReload( + sessionID: sessionID, session: currentSession, changes: changes) + fputs("Literal re-rendered in agent (no recompile)\n", stderr) + } catch { + fputs("JIT literal reload failed: \(error)\n", stderr) + } + return + } await MainActor.run { self.applyLiteralChanges(sessionID: sessionID, changes: changes) } @@ -206,6 +230,14 @@ public class PreviewHost: NSObject, NSApplicationDelegate { fputs("Session \(sessionID) no longer exists\n", stderr) return } + + if try await self.jitStructuralReload( + sessionID: sessionID, session: existingSession + ) != nil { + fputs("Reloaded (JIT agent)!\n", stderr); fflush(stderr) + return + } + let compileResult = try await existingSession.compile() fputs("Compiled: \(compileResult.dylibPath.lastPathComponent)\n", stderr) @@ -282,6 +314,7 @@ public class PreviewHost: NSObject, NSApplicationDelegate { fileWatchers[sessionID]?.stop() fileWatchers.removeValue(forKey: sessionID) sessions.removeValue(forKey: sessionID) + agentImagePaths.removeValue(forKey: sessionID) notifySessionsChanged() fputs("closePreview: watchers/sessions removed\n", stderr); fflush(stderr) @@ -316,6 +349,41 @@ public class PreviewHost: NSObject, NSApplicationDelegate { windows[sessionID] } + /// Structural reload via the JIT path: compile the preview to a render-bridge + /// object, render it in the agent, and record the agent's PNG for snapshots. + /// Returns the image path, or nil if no reloader is injected (non-JIT build). + @discardableResult + public func jitStructuralReload(sessionID: String, session: PreviewSession) async throws -> URL? { + guard let reloader = structuralReloader else { return nil } + let build = try await session.compileObjectForJIT() + try await reloader.render(build) + agentImagePaths[sessionID] = build.imagePath + return build.imagePath + } + + /// The agent-rendered PNG for a session, if it is on the JIT structural path. + public func agentSnapshotPath(for sessionID: String) -> URL? { + agentImagePaths[sessionID] + } + + /// Literal-only reload for an agent-backed session: rewrite the design-time values + /// JSON and re-render the same object in the agent — no recompile. Returns the new + /// image path, or nil if there is no reloader or no prior JIT build. + @discardableResult + public func jitLiteralReload( + sessionID: String, + session: PreviewSession, + changes: [(id: String, newValue: LiteralValue)] + ) async throws -> URL? { + guard + let reloader = structuralReloader, + let build = try await session.applyLiteralValuesForJIT(changes) + else { return nil } + try await reloader.render(build) + agentImagePaths[sessionID] = build.imagePath + return build.imagePath + } + /// Snapshot the current sessions and chain a publish Task. Each new /// Task `await`s the prior `lastPublishTask` before calling /// `publishSessions`, guaranteeing FIFO order at the registry even diff --git a/Sources/PreviewsMacOS/Snapshot.swift b/Sources/PreviewsMacOS/Snapshot.swift index 0d0cd21..dd3a2f6 100644 --- a/Sources/PreviewsMacOS/Snapshot.swift +++ b/Sources/PreviewsMacOS/Snapshot.swift @@ -59,6 +59,28 @@ public enum Snapshot { let data = try capture(window: window, format: format) try data.write(to: path) } + + /// Re-encode an agent-rendered image file (PNG) to the requested output format. + /// PNG output returns the bytes unchanged; JPEG transcodes. Used by the JIT + /// structural path, where the snapshot is rendered in the agent, not the window. + public static func encode(imageAt path: URL, format: ImageFormat) throws -> Data { + let data = try Data(contentsOf: path) + switch format { + case .png: + return data + case .jpeg(let quality): + guard + let rep = NSBitmapImageRep(data: data), + let out = rep.representation( + using: .jpeg, + properties: [.compressionFactor: NSNumber(value: quality)] + ) + else { + throw SnapshotError.encodingFailed + } + return out + } + } } public enum SnapshotError: Error, LocalizedError, CustomStringConvertible { diff --git a/Tests/PreviewsCoreTests/BuildSystemTests.swift b/Tests/PreviewsCoreTests/BuildSystemTests.swift index c571727..0ce3e38 100644 --- a/Tests/PreviewsCoreTests/BuildSystemTests.swift +++ b/Tests/PreviewsCoreTests/BuildSystemTests.swift @@ -382,7 +382,7 @@ struct BuildSystemTests { try "initial 2".write(to: file2, atomically: true, encoding: .utf8) let changed = Mutex(false) - let watcher = try FileWatcher(paths: [file1.path, file2.path]) { + let watcher = try FileWatcher(paths: [file1.path, file2.path]) { _ in changed.withLock { $0 = true } } defer { watcher.stop() } @@ -398,6 +398,32 @@ struct BuildSystemTests { #expect(didChange, "FileWatcher should detect modification of the second watched file") } + @Test("FileWatcher delivers the changed file path to the callback") + func fileWatcherDeliversChangedPath() async throws { + let tmpDir = FileManager.default.temporaryDirectory + .appendingPathComponent("fw-id-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tmpDir) } + + let file1 = tmpDir.appendingPathComponent("first.swift") + let file2 = tmpDir.appendingPathComponent("second.swift") + try "initial 1".write(to: file1, atomically: true, encoding: .utf8) + try "initial 2".write(to: file2, atomically: true, encoding: .utf8) + + let changedPath = Mutex(nil) + let watcher = try FileWatcher(paths: [file1.path, file2.path]) { path in + changedPath.withLock { $0 = path } + } + defer { watcher.stop() } + + try await Task.sleep(for: .milliseconds(100)) + try "modified 2".write(to: file2, atomically: true, encoding: .utf8) + try await Task.sleep(for: .milliseconds(200)) + + let captured = changedPath.withLock { $0 } + #expect(captured?.hasSuffix("second.swift") == true) + } + // MARK: - BuildContext @Test("BuildContext.supportsTier2 reflects sourceFiles presence") diff --git a/Tests/PreviewsCoreTests/IntegrationTests.swift b/Tests/PreviewsCoreTests/IntegrationTests.swift index 457ad75..ba4aa98 100644 --- a/Tests/PreviewsCoreTests/IntegrationTests.swift +++ b/Tests/PreviewsCoreTests/IntegrationTests.swift @@ -225,7 +225,7 @@ struct IntegrationTests { try "initial content".write(to: file, atomically: true, encoding: .utf8) let changed = Mutex(false) - let watcher = try FileWatcher(path: file.path) { + let watcher = try FileWatcher(path: file.path) { _ in changed.withLock { $0 = true } } defer { watcher.stop() } @@ -256,7 +256,7 @@ struct IntegrationTests { try "initial".write(to: file, atomically: false, encoding: .utf8) let changed = Mutex(false) - let watcher = try FileWatcher(path: file.path) { + let watcher = try FileWatcher(path: file.path) { _ in changed.withLock { $0 = true } } defer { watcher.stop() } @@ -304,7 +304,7 @@ struct IntegrationTests { try "initial".write(to: file, atomically: false, encoding: .utf8) let callCount = Mutex(0) - let watcher = try FileWatcher(path: file.path) { + let watcher = try FileWatcher(path: file.path) { _ in callCount.withLock { $0 += 1 } } defer { watcher.stop() } @@ -336,7 +336,7 @@ struct IntegrationTests { try "initial".write(to: file, atomically: false, encoding: .utf8) let callCount = Mutex(0) - let watcher = try FileWatcher(path: file.path) { + let watcher = try FileWatcher(path: file.path) { _ in callCount.withLock { $0 += 1 } } defer { watcher.stop() } diff --git a/Tests/PreviewsCoreTests/StructuralReloaderTests.swift b/Tests/PreviewsCoreTests/StructuralReloaderTests.swift new file mode 100644 index 0000000..dd1834e --- /dev/null +++ b/Tests/PreviewsCoreTests/StructuralReloaderTests.swift @@ -0,0 +1,102 @@ +import Foundation +import Testing + +@testable import PreviewsCore + +@Suite("StructuralReloader seam") +struct StructuralReloaderTests { + + private actor MockReloader: StructuralReloader { + private(set) var calls: [(objectPath: URL, entrySymbol: String)] = [] + func render(_ build: JITRenderBuild) async throws { + calls.append((objectPath: build.objectPath, entrySymbol: build.entrySymbol)) + } + func recorded() -> [(objectPath: URL, entrySymbol: String)] { calls } + } + + @Test("compileObjectForJIT emits a linkable .o carrying renderPreviewToFile") + func compilesJITObject() async throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("p34ci-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let sourceFile = dir.appendingPathComponent("ColorView.swift") + try """ + import SwiftUI + + struct ColorView: View { + var body: some View { + Color(red: 0, green: 1, blue: 0).frame(width: 8, height: 8) + } + } + + #Preview { + ColorView() + } + """.write(to: sourceFile, atomically: true, encoding: .utf8) + + let compiler = try await Compiler() + let session = PreviewSession(sourceFile: sourceFile, compiler: compiler) + + let build = try await session.compileObjectForJIT() + #expect(build.entrySymbol == "renderPreviewToFile") + #expect(FileManager.default.fileExists(atPath: build.objectPath.path)) + + let symbols = try Self.symbols(in: build.objectPath) + #expect(symbols.contains("_renderPreviewToFile")) + + let reloader = MockReloader() + try await reloader.render(build) + let calls = await reloader.recorded() + #expect(calls.count == 1) + #expect(calls.first?.objectPath == build.objectPath) + #expect(calls.first?.entrySymbol == "renderPreviewToFile") + } + + @Test("compileObjectForJIT writes design-time values JSON for seeding") + func writesDesignTimeValues() async throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("p34cii1-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let sourceFile = dir.appendingPathComponent("HelloView.swift") + try """ + import SwiftUI + + struct HelloView: View { + var body: some View { + Text("hello") + } + } + + #Preview { + HelloView() + } + """.write(to: sourceFile, atomically: true, encoding: .utf8) + + let compiler = try await Compiler() + let session = PreviewSession(sourceFile: sourceFile, compiler: compiler) + let build = try await session.compileObjectForJIT() + + #expect(FileManager.default.fileExists(atPath: build.valuesPath.path)) + #expect(!build.literals.isEmpty) + + let data = try Data(contentsOf: build.valuesPath) + let values = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + #expect(values.values.contains { ($0 as? String) == "hello" }) + } + + private static func symbols(in object: URL) throws -> String { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/nm") + process.arguments = ["-gU", object.path] + let pipe = Pipe() + process.standardOutput = pipe + try process.run() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + return String(decoding: data, as: UTF8.self) + } +} diff --git a/Tests/PreviewsEngineTests/MacOSPreviewHandleAgentSnapshotTests.swift b/Tests/PreviewsEngineTests/MacOSPreviewHandleAgentSnapshotTests.swift new file mode 100644 index 0000000..7c3d112 --- /dev/null +++ b/Tests/PreviewsEngineTests/MacOSPreviewHandleAgentSnapshotTests.swift @@ -0,0 +1,80 @@ +import AppKit +import Foundation +import PreviewsCore +import PreviewsMacOS +import Testing + +@testable import PreviewsEngine + +@MainActor +@Suite("MacOSPreviewHandle agent snapshot") +struct MacOSPreviewHandleAgentSnapshotTests { + + final class RecordingReloader: StructuralReloader, @unchecked Sendable { + func render(_ build: JITRenderBuild) async throws {} + } + + @Test func snapshotReturnsAgentImage() async throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("p34ci3b-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let sourceFile = dir.appendingPathComponent("ColorView.swift") + try """ + import SwiftUI + + struct ColorView: View { + var body: some View { + Color(red: 0, green: 1, blue: 0).frame(width: 8, height: 8) + } + } + + #Preview { + ColorView() + } + """.write(to: sourceFile, atomically: true, encoding: .utf8) + + let compiler = try await Compiler() + let session = PreviewSession(sourceFile: sourceFile, compiler: compiler) + let host = PreviewHost() + host.structuralReloader = RecordingReloader() + + let imagePath = try await host.jitStructuralReload(sessionID: "s1", session: session) + let imageURL = try #require(imagePath) + try Self.greenPNG().write(to: imageURL) + + let handle = MacOSPreviewHandle(id: "s1", session: session, host: host) + let data = try await handle.snapshot(quality: 1.0) + + let rep = try #require(NSBitmapImageRep(data: data)) + let color = try #require( + rep.colorAt(x: rep.pixelsWide / 2, y: rep.pixelsHigh / 2)? + .usingColorSpace(.deviceRGB) + ) + #expect(color.greenComponent > 0.8) + #expect(color.redComponent < 0.2) + #expect(color.blueComponent < 0.2) + } + + private static func greenPNG() throws -> Data { + guard + let rep = NSBitmapImageRep( + bitmapDataPlanes: nil, pixelsWide: 8, pixelsHigh: 8, + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, + colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0 + ) + else { + throw SnapshotError.encodingFailed + } + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep) + NSColor(deviceRed: 0, green: 1, blue: 0, alpha: 1).setFill() + NSRect(x: 0, y: 0, width: 8, height: 8).fill() + NSGraphicsContext.restoreGraphicsState() + guard let data = rep.representation(using: .png, properties: [:]) else { + throw SnapshotError.encodingFailed + } + return data + } +} diff --git a/Tests/PreviewsJITLinkTests/ArchiveLoadingTests.swift b/Tests/PreviewsJITLinkTests/ArchiveLoadingTests.swift new file mode 100644 index 0000000..21e2966 --- /dev/null +++ b/Tests/PreviewsJITLinkTests/ArchiveLoadingTests.swift @@ -0,0 +1,77 @@ +import Foundation +import PreviewsCore +import PreviewsJITLink +import Testing + +struct ArchiveLoadingTests { + private static func makeArchive(from object: URL, named name: String) throws -> URL { + let archive = object.deletingLastPathComponent() + .appendingPathComponent("lib\(name).a") + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/libtool") + process.arguments = ["-static", "-o", archive.path, object.path] + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { + throw JITLinkError.failed("libtool failed: \(process.terminationStatus)") + } + return archive + } + + @Test func resolvesSymbolFromStaticArchiveInAgent() async throws { + let compiler = try await Compiler() + + let libObject = try await compiler.compileObject( + source: """ + @_cdecl("g3_lib_value") + public func g3LibValue() -> Int32 { 7 } + """, + moduleName: "G3Lib" + ) + let archive = try Self.makeArchive(from: libObject, named: "G3Lib") + + let mainObject = try await compiler.compileObject( + source: """ + @_silgen_name("g3_lib_value") func g3LibValue() -> Int32 + + @_cdecl("g3_main") + public func g3Main() -> Int32 { g3LibValue() * 6 } + """, + moduleName: "G3Main" + ) + + let session = try JITSession(remoteAgentPath: JITSession.bundledAgentPath()) + try session.addArchive(path: archive.path) + try session.addObject(path: mainObject.path) + let result = try session.runOnMain(symbol: "g3_main") + #expect(result == 42) + } + + @Test func resolvesSymbolFromDylibInAgent() async throws { + let compiler = try await Compiler() + + let lib = try await compiler.compileCombined( + source: """ + @_cdecl("g3b_lib_value") + public func g3bLibValue() -> Int32 { 9 } + """, + moduleName: "G3bLib" + ) + + let mainObject = try await compiler.compileObject( + source: """ + @_silgen_name("g3b_lib_value") func g3bLibValue() -> Int32 + + @_cdecl("g3b_main") + public func g3bMain() -> Int32 { g3bLibValue() * 5 } + """, + moduleName: "G3bMain" + ) + + let session = try JITSession(remoteAgentPath: JITSession.bundledAgentPath()) + try session.addDylib(path: lib.dylibPath.path) + try session.addObject(path: mainObject.path) + let result = try session.runOnMain(symbol: "g3b_main") + #expect(result == 45) + } +} diff --git a/Tests/PreviewsJITLinkTests/CappedPersistentTests.swift b/Tests/PreviewsJITLinkTests/CappedPersistentTests.swift new file mode 100644 index 0000000..4f7ac6a --- /dev/null +++ b/Tests/PreviewsJITLinkTests/CappedPersistentTests.swift @@ -0,0 +1,132 @@ +import AppKit +import Foundation +import PreviewsCore +import PreviewsJITLink +import Testing + +struct CappedPersistentTests { + private static func renderSource(red: Int, green: Int, blue: Int) -> String { + """ + import SwiftUI + + @_cdecl("persistent_render_value") + public func persistent_render_value() -> Int32 { + MainActor.assumeIsolated { + let content = Color(red: \(red), green: \(green), blue: \(blue)) + .frame(width: 8, height: 8) + let renderer = ImageRenderer(content: content) + renderer.scale = 1 + guard let cgImage = renderer.cgImage else { return Int32(-1) } + let rep = NSBitmapImageRep(cgImage: cgImage) + guard + let color = rep.colorAt(x: rep.pixelsWide / 2, y: rep.pixelsHigh / 2)? + .usingColorSpace(.deviceRGB) + else { return Int32(-2) } + let r = Int32((color.redComponent * 255).rounded()) + let g = Int32((color.greenComponent * 255).rounded()) + let b = Int32((color.blueComponent * 255).rounded()) + return (r << 16) | (g << 8) | b + } + } + """ + } + + private static func designTimeStoreSymbols(in object: URL) throws -> Set { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/nm") + process.arguments = [object.path] + let pipe = Pipe() + process.standardOutput = pipe + try process.run() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + let text = String(decoding: data, as: UTF8.self) + let names = text.split(separator: "\n").compactMap { line -> String? in + line.split(separator: " ").last.map(String.init) + } + return Set(names.filter { $0.hasPrefix("_$s") && $0.contains("DesignTimeStore") }) + } + + @Test func editableModuleNameIsUniquePerCompile() async throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("unique-mod-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let sourceFile = dir.appendingPathComponent("ColorView.swift") + try """ + import SwiftUI + + #Preview { + Color(red: 0, green: 1, blue: 0).frame(width: 8, height: 8) + } + """.write(to: sourceFile, atomically: true, encoding: .utf8) + + let compiler = try await Compiler() + let session = PreviewSession(sourceFile: sourceFile, compiler: compiler) + + let b1 = try await session.compileObjectForJIT() + let b2 = try await session.compileObjectForJIT() + + let s1 = try Self.designTimeStoreSymbols(in: b1.objectPath) + let s2 = try Self.designTimeStoreSymbols(in: b2.objectPath) + #expect(!s1.isEmpty) + #expect(s1.isDisjoint(with: s2)) + } + + @Test func reloaderRespawnsAtGenerationCap() async throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("capped-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let sourceFile = dir.appendingPathComponent("ColorView.swift") + try """ + import SwiftUI + + #Preview { + Color(red: 0, green: 1, blue: 0).frame(width: 8, height: 8) + } + """.write(to: sourceFile, atomically: true, encoding: .utf8) + + let compiler = try await Compiler() + let session = PreviewSession(sourceFile: sourceFile, compiler: compiler) + let reloader = JITStructuralReloader(generationCap: 2) + + for _ in 0..<5 { + let build = try await session.compileObjectForJIT() + try await reloader.render(build) + let data = try Data(contentsOf: build.imagePath) + #expect(!data.isEmpty) + } + } + + @Test func reusesOneSessionAcrossFreshGenerations() async throws { + let compiler = try await Compiler() + let colors = [(1, 0, 0), (0, 1, 0), (0, 0, 1), (1, 0, 0), (0, 1, 0)] + var objects: [URL] = [] + for (r, g, b) in colors { + objects.append( + try await compiler.compileObject( + source: Self.renderSource(red: r, green: g, blue: b), + moduleName: "PersistentFixture" + ) + ) + } + + let session = try JITSession(remoteAgentPath: JITSession.bundledAgentPath()) + for (index, object) in objects.enumerated() { + if index > 0 { try session.newGeneration() } + try session.addObject(path: object.path) + let packed = Int(try session.runOnMain(symbol: "persistent_render_value")) + #expect(packed >= 0) + let r = (packed >> 16) & 0xFF + let g = (packed >> 8) & 0xFF + let b = packed & 0xFF + let (er, eg, eb) = colors[index] + #expect((er == 1) == (r > 200)) + #expect((eg == 1) == (g > 200)) + #expect((eb == 1) == (b > 200)) + } + } +} diff --git a/Tests/PreviewsJITLinkTests/CompilerObjectTests.swift b/Tests/PreviewsJITLinkTests/CompilerObjectTests.swift index 3da5e12..9296baa 100644 --- a/Tests/PreviewsJITLinkTests/CompilerObjectTests.swift +++ b/Tests/PreviewsJITLinkTests/CompilerObjectTests.swift @@ -1,3 +1,5 @@ +import AppKit +import Foundation import PreviewsCore import PreviewsJITLink import Testing @@ -51,4 +53,161 @@ struct CompilerObjectTests { #expect(result2 == 43) #expect(address1 != address2) } + + @Test func rerendersAfterRecompileInFreshAgent() async throws { + func renderSource(red: Int, green: Int, blue: Int) -> String { + """ + import SwiftUI + + @_cdecl("compiler_render_value") + public func compiler_render_value() -> Int32 { + MainActor.assumeIsolated { + let content = Color(red: \(red), green: \(green), blue: \(blue)) + .frame(width: 8, height: 8) + let renderer = ImageRenderer(content: content) + renderer.scale = 1 + guard let cgImage = renderer.cgImage else { return Int32(-1) } + let rep = NSBitmapImageRep(cgImage: cgImage) + guard + let color = rep.colorAt(x: rep.pixelsWide / 2, y: rep.pixelsHigh / 2)? + .usingColorSpace(.deviceRGB) + else { return Int32(-2) } + let r = Int32((color.redComponent * 255).rounded()) + let g = Int32((color.greenComponent * 255).rounded()) + let b = Int32((color.blueComponent * 255).rounded()) + return (r << 16) | (g << 8) | b + } + } + """ + } + + let compiler = try await Compiler() + + let v1 = try await compiler.compileObject( + source: renderSource(red: 1, green: 0, blue: 0), + moduleName: "CompilerRenderFixture" + ) + let v2 = try await compiler.compileObject( + source: renderSource(red: 0, green: 0, blue: 1), + moduleName: "CompilerRenderFixture" + ) + + let session1 = try JITSession(remoteAgentPath: JITSession.bundledAgentPath()) + try session1.addObject(path: v1.path) + let packed1 = try session1.runOnMain(symbol: "compiler_render_value") + + let session2 = try JITSession(remoteAgentPath: JITSession.bundledAgentPath()) + try session2.addObject(path: v2.path) + let packed2 = try session2.runOnMain(symbol: "compiler_render_value") + + #expect(packed1 >= 0) + let r1 = (packed1 >> 16) & 0xFF + let g1 = (packed1 >> 8) & 0xFF + let b1 = packed1 & 0xFF + #expect(r1 > 200 && g1 < 60 && b1 < 60) + + #expect(packed2 >= 0) + let r2 = (packed2 >> 16) & 0xFF + let g2 = (packed2 >> 8) & 0xFF + let b2 = packed2 & 0xFF + #expect(b2 > 200 && r2 < 60 && g2 < 60) + } + + @Test func rendersBitmapToFileFromAgent() async throws { + let outURL = FileManager.default.temporaryDirectory + .appendingPathComponent("p34a-render-\(UUID().uuidString).png") + defer { try? FileManager.default.removeItem(at: outURL) } + + let compiler = try await Compiler() + let object = try await compiler.compileObject( + source: """ + import SwiftUI + + @_cdecl("render_to_file") + public func render_to_file() -> Int32 { + MainActor.assumeIsolated { + let content = Color(red: 1, green: 0, blue: 0).frame(width: 8, height: 8) + let renderer = ImageRenderer(content: content) + renderer.scale = 1 + guard let cgImage = renderer.cgImage else { return Int32(-1) } + let rep = NSBitmapImageRep(cgImage: cgImage) + guard let data = rep.representation(using: .png, properties: [:]) else { + return Int32(-2) + } + do { + try data.write(to: URL(fileURLWithPath: "\(outURL.path)")) + } catch { + return Int32(-3) + } + return 0 + } + } + """, + moduleName: "RenderToFileFixture" + ) + + let session = try JITSession(remoteAgentPath: JITSession.bundledAgentPath()) + try session.addObject(path: object.path) + let status = try session.runOnMain(symbol: "render_to_file") + #expect(status == 0) + + let data = try Data(contentsOf: outURL) + #expect(!data.isEmpty) + let rep = try #require(NSBitmapImageRep(data: data)) + let color = try #require( + rep.colorAt(x: rep.pixelsWide / 2, y: rep.pixelsHigh / 2)? + .usingColorSpace(.deviceRGB) + ) + #expect(color.redComponent > 0.8) + #expect(color.greenComponent < 0.2) + #expect(color.blueComponent < 0.2) + } + + @Test func rendersRealBridgeToFileFromAgent() async throws { + let outURL = FileManager.default.temporaryDirectory + .appendingPathComponent("p34b-bridge-\(UUID().uuidString).png") + defer { try? FileManager.default.removeItem(at: outURL) } + + let originalSource = """ + import SwiftUI + + struct ColorView: View { + var body: some View { + Color(red: 0, green: 1, blue: 0).frame(width: 8, height: 8) + } + } + + #Preview { + ColorView() + } + """ + + let generated = BridgeGenerator.generateCombinedSource( + originalSource: originalSource, + closureBody: "ColorView()", + renderOutputPath: outURL.path + ) + + let compiler = try await Compiler() + let object = try await compiler.compileObject( + source: generated.source, + moduleName: "RealBridgeFixture" + ) + + let session = try JITSession(remoteAgentPath: JITSession.bundledAgentPath()) + try session.addObject(path: object.path) + let status = try session.runOnMain(symbol: "renderPreviewToFile") + #expect(status == 0) + + let data = try Data(contentsOf: outURL) + #expect(!data.isEmpty) + let rep = try #require(NSBitmapImageRep(data: data)) + let color = try #require( + rep.colorAt(x: rep.pixelsWide / 2, y: rep.pixelsHigh / 2)? + .usingColorSpace(.deviceRGB) + ) + #expect(color.greenComponent > 0.8) + #expect(color.redComponent < 0.2) + #expect(color.blueComponent < 0.2) + } } diff --git a/Tests/PreviewsJITLinkTests/ExamplesSplitE2ETests.swift b/Tests/PreviewsJITLinkTests/ExamplesSplitE2ETests.swift new file mode 100644 index 0000000..ce0af8b --- /dev/null +++ b/Tests/PreviewsJITLinkTests/ExamplesSplitE2ETests.swift @@ -0,0 +1,101 @@ +import AppKit +import Foundation +import PreviewsCore +import PreviewsJITLink +import Testing + +/// End-to-end validation that the P4.1 recompile-narrowing split works against a real SPM +/// example: a real `BuildContext` from `SPMBuildSystem` (real `-I`/`-L`/`-package-name` +/// flags, real multi-file target, sibling + cross-package deps), driven through +/// `PreviewSession.compileObjectForJIT()`. +/// +/// A real Tier-2 preview renders end to end here. The dependency closure is loaded into the +/// agent: G3-a (static archives, ToDoExtras / LocalDep), G3-b (dlopen binary frameworks, +/// Lottie), and G3-c (the compiler-rt builtins archive `libclang_rt.osx.a`, which provides +/// `__isPlatformVersionAtLeast` emitted by `#available`). See `docs/jit-executor-phase3-plan.md`. +@Suite(.serialized) +struct ExamplesSplitE2ETests { + static let repoRoot: URL = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // PreviewsJITLinkTests/ + .deletingLastPathComponent() // Tests/ + .deletingLastPathComponent() // repo root + static let spmRoot = repoRoot.appendingPathComponent("examples/spm") + + enum E2EError: Error { case noBuildSystem } + + static func context(for hotFile: URL) async throws -> BuildContext { + guard let spm = try await SPMBuildSystem.detect(for: hotFile) else { + throw E2EError.noBuildSystem + } + return try await spm.build(platform: .macOS) + } + + @Test func splitCompilesRealCrossFilePreviewWithDeps() async throws { + let hot = Self.spmRoot.appendingPathComponent("Sources/ToDo/Summary.swift") + let ctx = try await Self.context(for: hot) + let compiler = try await Compiler() + let session = PreviewSession(sourceFile: hot, compiler: compiler, buildContext: ctx) + + let build = try await session.compileObjectForJIT() + + #expect(!build.supportObjectPaths.isEmpty) + #expect(FileManager.default.fileExists(atPath: build.objectPath.path)) + for support in build.supportObjectPaths { + #expect(FileManager.default.fileExists(atPath: support.path)) + } + #expect(build.entrySymbol == "renderPreviewToFile") + } + + @Test func literalUpdateClassifiedOnRealPreview() async throws { + let hot = Self.spmRoot.appendingPathComponent("Sources/ToDo/BadgePreview.swift") + let ctx = try await Self.context(for: hot) + let compiler = try await Compiler() + let session = PreviewSession(sourceFile: hot, compiler: compiler, buildContext: ctx) + + let build1 = try await session.compileObjectForJIT() + + let original = try String(contentsOf: hot, encoding: .utf8) + let edited = original.replacingOccurrences(of: "In Progress", with: "Reviewing") + #expect(edited != original) + + let changes = try #require(await session.tryLiteralUpdate(newSource: edited)) + #expect(!changes.isEmpty) + + let build2 = try #require(try await session.applyLiteralValuesForJIT(changes)) + #expect(build2.objectPath == build1.objectPath) + } + + @Test func splitRendersRealPreviewInAgent() async throws { + let hot = Self.spmRoot.appendingPathComponent("Sources/ToDo/Summary.swift") + let ctx = try await Self.context(for: hot) + let compiler = try await Compiler() + let session = PreviewSession(sourceFile: hot, compiler: compiler, buildContext: ctx) + let build = try await session.compileObjectForJIT() + + let reloader = JITStructuralReloader() + try await reloader.render(build) + let png = try Data(contentsOf: build.imagePath) + #expect(!png.isEmpty) + #expect(NSBitmapImageRep(data: png) != nil) + } + + /// Capped-persistent reuse with the FULL dependency closure: render the same real preview + /// twice through one reloader, so generation 2 re-links ToDoExtras / Lottie / the builtins + /// archive into a fresh JITDylib. Guards against duplicate Swift-metadata registration + /// across generations (item 2, U2). + @Test func splitRendersRealPreviewAcrossGenerations() async throws { + let hot = Self.spmRoot.appendingPathComponent("Sources/ToDo/Summary.swift") + let ctx = try await Self.context(for: hot) + let compiler = try await Compiler() + let session = PreviewSession(sourceFile: hot, compiler: compiler, buildContext: ctx) + let reloader = JITStructuralReloader() + + for _ in 0..<2 { + let build = try await session.compileObjectForJIT() + try await reloader.render(build) + let png = try Data(contentsOf: build.imagePath) + #expect(!png.isEmpty) + #expect(NSBitmapImageRep(data: png) != nil) + } + } +} diff --git a/Tests/PreviewsJITLinkTests/Fixtures/hosting_probe.swift b/Tests/PreviewsJITLinkTests/Fixtures/hosting_probe.swift new file mode 100644 index 0000000..58f8a8f --- /dev/null +++ b/Tests/PreviewsJITLinkTests/Fixtures/hosting_probe.swift @@ -0,0 +1,14 @@ +import SwiftUI + +struct HostingProbeView: View { + var body: some View { + Text("hi").padding() + } +} + +@_cdecl("hosting_probe_value") +public func hosting_probe_value() -> Int32 { + let hosting = NSHostingView(rootView: HostingProbeView()) + hosting.layoutSubtreeIfNeeded() + return hosting.fittingSize.width > 0 ? 1 : 0 +} diff --git a/Tests/PreviewsJITLinkTests/Fixtures/render_probe.swift b/Tests/PreviewsJITLinkTests/Fixtures/render_probe.swift new file mode 100644 index 0000000..dae9382 --- /dev/null +++ b/Tests/PreviewsJITLinkTests/Fixtures/render_probe.swift @@ -0,0 +1,24 @@ +import SwiftUI + +@_cdecl("render_probe_value") +public func render_probe_value() -> Int32 { + MainActor.assumeIsolated { + let content = Color(red: 1, green: 0, blue: 0).frame(width: 8, height: 8) + let renderer = ImageRenderer(content: content) + renderer.scale = 1 + guard let cgImage = renderer.cgImage else { + return Int32(-1) + } + let rep = NSBitmapImageRep(cgImage: cgImage) + guard + let color = rep.colorAt(x: rep.pixelsWide / 2, y: rep.pixelsHigh / 2)? + .usingColorSpace(.deviceRGB) + else { + return Int32(-2) + } + let r = Int32((color.redComponent * 255).rounded()) + let g = Int32((color.greenComponent * 255).rounded()) + let b = Int32((color.blueComponent * 255).rounded()) + return (r << 16) | (g << 8) | b + } +} diff --git a/Tests/PreviewsJITLinkTests/Fixtures/swiftui_probe.swift b/Tests/PreviewsJITLinkTests/Fixtures/swiftui_probe.swift new file mode 100644 index 0000000..7541db1 --- /dev/null +++ b/Tests/PreviewsJITLinkTests/Fixtures/swiftui_probe.swift @@ -0,0 +1,15 @@ +import SwiftUI + +struct ProbeView: View { + let value: Int32 + var body: some View { + Text(String(value)) + } +} + +@_cdecl("swiftui_probe_value") +public func swiftui_probe_value() -> Int32 { + let view = ProbeView(value: 7) + _ = view.body + return view.value +} diff --git a/Tests/PreviewsJITLinkTests/JITStructuralReloaderTests.swift b/Tests/PreviewsJITLinkTests/JITStructuralReloaderTests.swift new file mode 100644 index 0000000..ffde088 --- /dev/null +++ b/Tests/PreviewsJITLinkTests/JITStructuralReloaderTests.swift @@ -0,0 +1,104 @@ +import AppKit +import Foundation +import PreviewsCore +import PreviewsJITLink +import Testing + +struct JITStructuralReloaderTests { + @Test func reloaderRendersCompileObjectForJIT() async throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("p34ci2-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let sourceFile = dir.appendingPathComponent("ColorView.swift") + try """ + import SwiftUI + + struct ColorView: View { + var body: some View { + Color(red: 0, green: 1, blue: 0).frame(width: 8, height: 8) + } + } + + #Preview { + ColorView() + } + """.write(to: sourceFile, atomically: true, encoding: .utf8) + + let compiler = try await Compiler() + let session = PreviewSession(sourceFile: sourceFile, compiler: compiler) + let build = try await session.compileObjectForJIT() + + let reloader = JITStructuralReloader() + try await reloader.render(build) + + let data = try Data(contentsOf: build.imagePath) + #expect(!data.isEmpty) + let rep = try #require(NSBitmapImageRep(data: data)) + let color = try #require( + rep.colorAt(x: rep.pixelsWide / 2, y: rep.pixelsHigh / 2)? + .usingColorSpace(.deviceRGB) + ) + #expect(color.greenComponent > 0.8) + #expect(color.redComponent < 0.2) + #expect(color.blueComponent < 0.2) + } + + @Test func literalRewriteReRendersSameObjectNoRecompile() async throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("p34cii2-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let sourceFile = dir.appendingPathComponent("GrayView.swift") + try """ + import SwiftUI + + struct GrayView: View { + var body: some View { + Color(white: 0.2).frame(width: 8, height: 8) + } + } + + #Preview { + GrayView() + } + """.write(to: sourceFile, atomically: true, encoding: .utf8) + + let compiler = try await Compiler() + let session = PreviewSession(sourceFile: sourceFile, compiler: compiler) + let build = try await session.compileObjectForJIT() + let reloader = JITStructuralReloader() + + try await reloader.render(build) + let b1 = try Self.centerBrightness(build.imagePath) + #expect(b1 < 0.4) + + let whiteLiteral = try #require( + build.literals.first { if case .float = $0.value { return true } else { return false } } + ) + var values = try #require( + JSONSerialization.jsonObject(with: Data(contentsOf: build.valuesPath)) as? [String: Any] + ) + values[whiteLiteral.id] = 0.9 + try JSONSerialization.data(withJSONObject: values).write(to: build.valuesPath) + + try await reloader.render(build) + let b2 = try Self.centerBrightness(build.imagePath) + #expect(b2 > 0.7) + #expect(b2 > b1) + } + + private static func centerBrightness(_ pngURL: URL) throws -> Double { + let data = try Data(contentsOf: pngURL) + guard + let rep = NSBitmapImageRep(data: data), + let color = rep.colorAt(x: rep.pixelsWide / 2, y: rep.pixelsHigh / 2)? + .usingColorSpace(.deviceRGB) + else { + throw JITReloadError.renderFailed(status: -99) + } + return color.redComponent + } +} diff --git a/Tests/PreviewsJITLinkTests/PreviewSessionSplitTests.swift b/Tests/PreviewsJITLinkTests/PreviewSessionSplitTests.swift new file mode 100644 index 0000000..206187f --- /dev/null +++ b/Tests/PreviewsJITLinkTests/PreviewSessionSplitTests.swift @@ -0,0 +1,175 @@ +import AppKit +import Foundation +import PreviewsCore +import PreviewsJITLink +import Testing + +struct PreviewSessionSplitTests { + private struct Project { + let dir: URL + let hotFile: URL + let context: BuildContext + } + + /// Lay down a temp Tier-2 project: a stable "bulk" file exposing a `Palette` the preview + /// consumes cross-module, plus the hot preview file. `BuildContext.sourceFiles` excludes + /// the hot file (matching the build systems), so it is the stable bulk. + private static func makeProject(previewBody: String) throws -> Project { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("split-session-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + + let paletteFile = dir.appendingPathComponent("Palette.swift") + try """ + import SwiftUI + + struct Palette { + static func square(red: Double, green: Double, blue: Double) -> some View { + Color(red: red, green: green, blue: blue).frame(width: 8, height: 8) + } + } + """.write(to: paletteFile, atomically: true, encoding: .utf8) + + let hotFile = dir.appendingPathComponent("PreviewView.swift") + try """ + import SwiftUI + + #Preview { + \(previewBody) + } + """.write(to: hotFile, atomically: true, encoding: .utf8) + + let context = BuildContext( + moduleName: "DemoApp", + compilerFlags: [], + projectRoot: dir, + targetName: "DemoApp", + sourceFiles: [paletteFile] + ) + return Project(dir: dir, hotFile: hotFile, context: context) + } + + private static func renderColor(_ build: JITRenderBuild) throws -> NSColor { + let session = try JITSession(remoteAgentPath: JITSession.bundledAgentPath()) + for object in build.supportObjectPaths { + try session.addObject(path: object.path) + } + try session.addObject(path: build.objectPath.path) + let status = try session.runOnMain(symbol: build.entrySymbol) + #expect(status == 0) + + let rep = try #require(NSBitmapImageRep(data: Data(contentsOf: build.imagePath))) + return try #require( + rep.colorAt(x: rep.pixelsWide / 2, y: rep.pixelsHigh / 2)? + .usingColorSpace(.deviceRGB) + ) + } + + @Test func splitCompileRendersHotFileAgainstStableBulk() async throws { + let project = try Self.makeProject( + previewBody: "Palette.square(red: 1, green: 0, blue: 0)") + defer { try? FileManager.default.removeItem(at: project.dir) } + + let compiler = try await Compiler() + let session = PreviewSession( + sourceFile: project.hotFile, compiler: compiler, buildContext: project.context) + + let build = try await session.compileObjectForJIT() + #expect(!build.supportObjectPaths.isEmpty) + + let color = try Self.renderColor(build) + #expect(color.redComponent > 0.8) + #expect(color.greenComponent < 0.2) + #expect(color.blueComponent < 0.2) + } + + @Test func stableModuleCachedAcrossHotEditsAndInvalidatedByBulkChange() async throws { + let project = try Self.makeProject( + previewBody: "Palette.square(red: 1, green: 0, blue: 0)") + defer { try? FileManager.default.removeItem(at: project.dir) } + + let compiler = try await Compiler() + let session = PreviewSession( + sourceFile: project.hotFile, compiler: compiler, buildContext: project.context) + + let build1 = try await session.compileObjectForJIT() + + try """ + import SwiftUI + + #Preview { + Palette.square(red: 0, green: 1, blue: 0) + } + """.write(to: project.hotFile, atomically: true, encoding: .utf8) + let build2 = try await session.compileObjectForJIT() + + #expect(!build1.supportObjectPaths.isEmpty) + #expect(build1.supportObjectPaths == build2.supportObjectPaths) + + let bulkFile = project.dir.appendingPathComponent("Palette.swift") + try """ + import SwiftUI + + struct Palette { + static let tag = 7 + static func square(red: Double, green: Double, blue: Double) -> some View { + Color(red: red, green: green, blue: blue).frame(width: 8, height: 8) + } + } + """.write(to: bulkFile, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.modificationDate: Date(timeIntervalSinceNow: 5)], ofItemAtPath: bulkFile.path) + let build3 = try await session.compileObjectForJIT() + + #expect(build3.supportObjectPaths != build2.supportObjectPaths) + } + + @Test func reloaderRendersSplitBuildThroughBothObjects() async throws { + let project = try Self.makeProject( + previewBody: "Palette.square(red: 1, green: 0, blue: 0)") + defer { try? FileManager.default.removeItem(at: project.dir) } + + let compiler = try await Compiler() + let session = PreviewSession( + sourceFile: project.hotFile, compiler: compiler, buildContext: project.context) + let build = try await session.compileObjectForJIT() + + let reloader = JITStructuralReloader() + try await reloader.render(build) + + let rep = try #require(NSBitmapImageRep(data: Data(contentsOf: build.imagePath))) + let color = try #require( + rep.colorAt(x: rep.pixelsWide / 2, y: rep.pixelsHigh / 2)? + .usingColorSpace(.deviceRGB) + ) + #expect(color.redComponent > 0.8) + #expect(color.greenComponent < 0.2) + #expect(color.blueComponent < 0.2) + } + + @Test func structuralEditReRendersThroughSplit() async throws { + let project = try Self.makeProject( + previewBody: "Palette.square(red: 1, green: 0, blue: 0)") + defer { try? FileManager.default.removeItem(at: project.dir) } + + let compiler = try await Compiler() + let session = PreviewSession( + sourceFile: project.hotFile, compiler: compiler, buildContext: project.context) + + let red = try await session.compileObjectForJIT() + let redColor = try Self.renderColor(red) + #expect(redColor.redComponent > 0.8 && redColor.greenComponent < 0.2) + + try """ + import SwiftUI + + #Preview { + Palette.square(red: 0, green: 0, blue: 1) + } + """.write(to: project.hotFile, atomically: true, encoding: .utf8) + + let blue = try await session.compileObjectForJIT() + let blueColor = try Self.renderColor(blue) + #expect(blueColor.blueComponent > 0.8 && blueColor.redComponent < 0.2) + } +} diff --git a/Tests/PreviewsJITLinkTests/PreviewsJITLinkTests.swift b/Tests/PreviewsJITLinkTests/PreviewsJITLinkTests.swift index 1f1267a..685c5df 100644 --- a/Tests/PreviewsJITLinkTests/PreviewsJITLinkTests.swift +++ b/Tests/PreviewsJITLinkTests/PreviewsJITLinkTests.swift @@ -91,6 +91,34 @@ struct PreviewsJITLinkTests { #expect(result == 11) } + @Test func runsSwiftUIViewBodyRemotely() throws { + let object = try FixtureSupport.compile("swiftui_probe.swift") + let session = try JITSession(remoteAgentPath: JITSession.bundledAgentPath()) + try session.addObject(path: object.path) + let result = try session.runMain(symbol: "swiftui_probe_value") + #expect(result == 7) + } + + @Test func buildsHostingViewOnMainThreadRemotely() throws { + let object = try FixtureSupport.compile("hosting_probe.swift") + let session = try JITSession(remoteAgentPath: JITSession.bundledAgentPath()) + try session.addObject(path: object.path) + let result = try session.runOnMain(symbol: "hosting_probe_value") + #expect(result == 1) + } + + @Test func rendersViewToBitmapOnMainThreadRemotely() throws { + let object = try FixtureSupport.compile("render_probe.swift") + let session = try JITSession(remoteAgentPath: JITSession.bundledAgentPath()) + try session.addObject(path: object.path) + let packed = try session.runOnMain(symbol: "render_probe_value") + #expect(packed >= 0) + let r = (packed >> 16) & 0xFF + let g = (packed >> 8) & 0xFF + let b = packed & 0xFF + #expect(r > 200 && g < 60 && b < 60) + } + @Test func publishesNewAddressIntoSlotRemotely() throws { let object = try FixtureSupport.compile("patch_slot.c") let session = try JITSession(remoteAgentPath: JITSession.bundledAgentPath()) diff --git a/Tests/PreviewsJITLinkTests/SplitCompileTests.swift b/Tests/PreviewsJITLinkTests/SplitCompileTests.swift new file mode 100644 index 0000000..3672593 --- /dev/null +++ b/Tests/PreviewsJITLinkTests/SplitCompileTests.swift @@ -0,0 +1,170 @@ +import AppKit +import Foundation +import PreviewsCore +import PreviewsJITLink +import Testing + +struct SplitCompileTests { + private static func ms(_ duration: Duration) -> Double { + let c = duration.components + return Double(c.seconds) * 1000 + Double(c.attoseconds) / 1e15 + } + + /// Bulk sources for the stable module. File 0 exposes the `bulkSquare` API the editable + /// unit renders (so the split proves cross-module symbol use, not just a dangling import); + /// files 1.. [String] { + var sources = [ + """ + import SwiftUI + + func bulkSquare(red: Double, green: Double, blue: Double) -> some View { + Color(red: red, green: green, blue: blue).frame(width: 8, height: 8) + } + """ + ] + for i in 1.. String { + """ + import AppKit + import SwiftUI + + @testable import SplitBulk + + @_cdecl("split_render_value") + public func split_render_value() -> Int32 { + MainActor.assumeIsolated { + let content = bulkSquare(red: 1, green: \(green), blue: 0) + let renderer = ImageRenderer(content: content) + renderer.scale = 1 + guard let cgImage = renderer.cgImage else { return Int32(-1) } + let rep = NSBitmapImageRep(cgImage: cgImage) + guard + let color = rep.colorAt(x: rep.pixelsWide / 2, y: rep.pixelsHigh / 2)? + .usingColorSpace(.deviceRGB) + else { return Int32(-2) } + let r = Int32((color.redComponent * 255).rounded()) + let g = Int32((color.greenComponent * 255).rounded()) + let b = Int32((color.blueComponent * 255).rounded()) + return (r << 16) | (g << 8) | b + } + } + """ + } + + private static func renderViaAgent(stable: Compiler.StableModule, editable: URL) throws -> Int { + let session = try JITSession(remoteAgentPath: JITSession.bundledAgentPath()) + try session.addObject(path: stable.objectPath.path) + try session.addObject(path: editable.path) + return Int(try session.runOnMain(symbol: "split_render_value")) + } + + @Test func editableUnitCompilesAgainstPrebuiltStableModuleAndRenders() async throws { + let compiler = try await Compiler() + let stable = try await compiler.emitStableModule( + sources: Self.bulkSources(count: 8), + moduleName: "SplitBulk" + ) + + let v1 = try await compiler.compileObject( + source: Self.editableUnit(green: 0), + moduleName: "SplitEdit", + extraFlags: ["-I", stable.modulesDir.path] + ) + let packed1 = try Self.renderViaAgent(stable: stable, editable: v1) + #expect(packed1 >= 0) + let r1 = (packed1 >> 16) & 0xFF + let g1 = (packed1 >> 8) & 0xFF + let b1 = packed1 & 0xFF + #expect(r1 > 200 && g1 < 60 && b1 < 60) + } + + @Test func structuralEditReusesStableModuleAcrossEdits() async throws { + let compiler = try await Compiler() + let stable = try await compiler.emitStableModule( + sources: Self.bulkSources(count: 8), + moduleName: "SplitBulk" + ) + + let v1 = try await compiler.compileObject( + source: Self.editableUnit(green: 0), + moduleName: "SplitEdit", + extraFlags: ["-I", stable.modulesDir.path] + ) + let packed1 = try Self.renderViaAgent(stable: stable, editable: v1) + + let v2 = try await compiler.compileObject( + source: Self.editableUnit(green: 1), + moduleName: "SplitEdit", + extraFlags: ["-I", stable.modulesDir.path] + ) + let packed2 = try Self.renderViaAgent(stable: stable, editable: v2) + + #expect(packed1 >= 0) + let r1 = (packed1 >> 16) & 0xFF + let g1 = (packed1 >> 8) & 0xFF + let b1 = packed1 & 0xFF + #expect(r1 > 200 && g1 < 60 && b1 < 60) + + #expect(packed2 >= 0) + let r2 = (packed2 >> 16) & 0xFF + let g2 = (packed2 >> 8) & 0xFF + let b2 = packed2 & 0xFF + #expect(r2 > 200 && g2 > 200 && b2 < 60) + } + + /// The per-edit compile recompiles only the editable unit against the prebuilt stable + /// module, so it does not grow with module size; the whole-module baseline does. With a + /// realistic SwiftUI bulk the split is well under the baseline. (W5/W7: flat ~0.14s.) + @Test func perEditRecompileIsBelowWholeModuleCompile() async throws { + let count = 24 + let compiler = try await Compiler() + let bulk = Self.bulkSources(count: count) + let stable = try await compiler.emitStableModule(sources: bulk, moduleName: "SplitBulk") + + let clock = ContinuousClock() + let s0 = clock.now + _ = try await compiler.compileObject( + source: Self.editableUnit(green: 1), + moduleName: "SplitEdit", + extraFlags: ["-I", stable.modulesDir.path] + ) + let splitMs = Self.ms(s0.duration(to: clock.now)) + + let wholeSource = + bulk.joined(separator: "\n\n") + "\n\n" + + Self.editableUnit(green: 1).replacingOccurrences( + of: "@testable import SplitBulk", with: "") + let w0 = clock.now + _ = try await compiler.compileObject(source: wholeSource, moduleName: "SplitWhole") + let wholeMs = Self.ms(w0.duration(to: clock.now)) + + print( + "P4.1 split compile (N=\(count)): editable-only=\(Int(splitMs))ms " + + "whole-module=\(Int(wholeMs))ms" + ) + #expect(splitMs < wholeMs) + } +} diff --git a/Tests/PreviewsJITLinkTests/StructuralReloadLatencyTests.swift b/Tests/PreviewsJITLinkTests/StructuralReloadLatencyTests.swift new file mode 100644 index 0000000..3a4f022 --- /dev/null +++ b/Tests/PreviewsJITLinkTests/StructuralReloadLatencyTests.swift @@ -0,0 +1,58 @@ +import Foundation +import PreviewsCore +import PreviewsJITLink +import Testing + +struct StructuralReloadLatencyTests { + private static func ms(_ duration: Duration) -> Double { + let c = duration.components + return Double(c.seconds) * 1000 + Double(c.attoseconds) / 1e15 + } + + @Test func measuresStructuralReloadLatency() async throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("p34d-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let sourceFile = dir.appendingPathComponent("ColorView.swift") + try """ + import SwiftUI + + struct ColorView: View { + var body: some View { + Color(red: 0, green: 1, blue: 0).frame(width: 8, height: 8) + } + } + + #Preview { + ColorView() + } + """.write(to: sourceFile, atomically: true, encoding: .utf8) + + let compiler = try await Compiler() + let session = PreviewSession(sourceFile: sourceFile, compiler: compiler) + let reloader = JITStructuralReloader() + + // Warm up: first compile pays module-cache warmup, first agent pays spawn cost. + let warm = try await session.compileObjectForJIT() + try await reloader.render(warm) + + let clock = ContinuousClock() + let t0 = clock.now + let build = try await session.compileObjectForJIT() + let t1 = clock.now + try await reloader.render(build) + let t2 = clock.now + + let compileMs = Self.ms(t0.duration(to: t1)) + let renderMs = Self.ms(t1.duration(to: t2)) + print( + "P3.4d structural reload (small module, respawn-per-edit): " + + "compile=\(Int(compileMs))ms render=\(Int(renderMs))ms " + + "total=\(Int(compileMs + renderMs))ms" + ) + + #expect(compileMs + renderMs < 30000) + } +} diff --git a/Tests/PreviewsMacOSTests/PreviewHostJITReloadTests.swift b/Tests/PreviewsMacOSTests/PreviewHostJITReloadTests.swift new file mode 100644 index 0000000..19b50fa --- /dev/null +++ b/Tests/PreviewsMacOSTests/PreviewHostJITReloadTests.swift @@ -0,0 +1,115 @@ +import Foundation +import PreviewsCore +import Testing + +@testable import PreviewsMacOS + +@MainActor +@Suite("PreviewHost JIT structural reload") +struct PreviewHostJITReloadTests { + + final class RecordingReloader: StructuralReloader, @unchecked Sendable { + private(set) var calls: [(objectPath: URL, entrySymbol: String)] = [] + func render(_ build: JITRenderBuild) async throws { + calls.append((objectPath: build.objectPath, entrySymbol: build.entrySymbol)) + } + } + + @Test func structuralReloadRecordsAgentImage() async throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("p34ci3a-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let sourceFile = dir.appendingPathComponent("ColorView.swift") + try """ + import SwiftUI + + struct ColorView: View { + var body: some View { + Color(red: 0, green: 1, blue: 0).frame(width: 8, height: 8) + } + } + + #Preview { + ColorView() + } + """.write(to: sourceFile, atomically: true, encoding: .utf8) + + let compiler = try await Compiler() + let session = PreviewSession(sourceFile: sourceFile, compiler: compiler) + + let host = PreviewHost() + let reloader = RecordingReloader() + host.structuralReloader = reloader + + let imagePath = try await host.jitStructuralReload(sessionID: "s1", session: session) + #expect(imagePath != nil) + #expect(host.agentSnapshotPath(for: "s1") == imagePath) + #expect(reloader.calls.count == 1) + #expect(reloader.calls.first?.entrySymbol == "renderPreviewToFile") + } + + @Test func literalReloadRewritesValuesAndRecordsImage() async throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("p34cii3-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let sourceFile = dir.appendingPathComponent("HelloView.swift") + try """ + import SwiftUI + + struct HelloView: View { + var body: some View { + Text("hello") + } + } + + #Preview { + HelloView() + } + """.write(to: sourceFile, atomically: true, encoding: .utf8) + + let compiler = try await Compiler() + let session = PreviewSession(sourceFile: sourceFile, compiler: compiler) + let build = try await session.compileObjectForJIT() + + let host = PreviewHost() + host.structuralReloader = RecordingReloader() + + let stringLiteral = try #require( + build.literals.first { if case .string = $0.value { return true } else { return false } } + ) + let img = try await host.jitLiteralReload( + sessionID: "s1", + session: session, + changes: [(id: stringLiteral.id, newValue: .string("world"))] + ) + + #expect(img == build.imagePath) + #expect(host.agentSnapshotPath(for: "s1") == build.imagePath) + + let values = try #require( + JSONSerialization.jsonObject(with: Data(contentsOf: build.valuesPath)) as? [String: Any] + ) + #expect((values[stringLiteral.id] as? String) == "world") + } + + @Test func noReloaderFallsThrough() async throws { + let host = PreviewHost() + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("p34ci3a-nil-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + let sourceFile = dir.appendingPathComponent("Empty.swift") + try "import SwiftUI\n".write(to: sourceFile, atomically: true, encoding: .utf8) + + let compiler = try await Compiler() + let session = PreviewSession(sourceFile: sourceFile, compiler: compiler) + + let imagePath = try await host.jitStructuralReload(sessionID: "s2", session: session) + #expect(imagePath == nil) + #expect(host.agentSnapshotPath(for: "s2") == nil) + } +} diff --git a/Tests/PreviewsMacOSTests/PreviewHostTests.swift b/Tests/PreviewsMacOSTests/PreviewHostTests.swift index efa5bc9..4ca3c5d 100644 --- a/Tests/PreviewsMacOSTests/PreviewHostTests.swift +++ b/Tests/PreviewsMacOSTests/PreviewHostTests.swift @@ -35,7 +35,7 @@ struct PreviewHostTests { // before we touch the file. Without `retainFileWatcher` the watcher // would deinit here and the callback below would never fire. do { - let watcher = try FileWatcher(path: file.path) { + let watcher = try FileWatcher(path: file.path) { _ in fired.withLock { $0 = true } } host.retainFileWatcher(watcher) diff --git a/docs/jit-executor-phase3-plan.md b/docs/jit-executor-phase3-plan.md new file mode 100644 index 0000000..6f08f9a --- /dev/null +++ b/docs/jit-executor-phase3-plan.md @@ -0,0 +1,830 @@ +# JIT Executor — Phase 3 plan and state + +Living plan for Phase 3, "SwiftUI session-lifecycle integration + hot-reload." +Resume from here across sessions. Update it as work lands. Phase 1 (issue #183) +is on `main` via PR #185; Phase 2 via PR #186. Phase 3 tracks issue #189 on +branch `jit-phase3-session-integration` (PR #190). + +## Sources of truth + +- `docs/jit-executor-phase1-plan.md`, `docs/jit-executor-phase2-plan.md` + (landed state, discoveries, gotchas). +- `prompts/jit-executor-design.md` §§2, 4, 5, 6, 7, 8.Phase-3 (on + `previews-research`). +- Auto-memory: `project_jit_inprocess_harness`, `project_jit_dynamic_replacement`, + `project_jit_spike_outcome`. +- Branch: `jit-phase3-session-integration` (off `main` at `4de49e2`). + +## Goal + +Wire the JIT executor into PreviewsMCP's session lifecycle. The existing +FileWatcher + Compiler + SessionResolver pipeline routes **structural** edits to +JIT-link instead of thunk-rebuild; **literal-only** edits stay on the +thunk/`DesignTimeStore` path (`LiteralRegionClassifier` already classifies +them). Run a real SwiftUI `View` body in the agent and render it, then drive +edits without restarting the daemon. The jump from Phase 2: Phase 2 only ran +nullary `@_cdecl` functions via `runAsMain`; Phase 3 runs a real `View` body and +renders, so the agent needs a SwiftUI/render harness and a calling surface +beyond `runAsMain`. The deferred SP5 (richer `SessionResolver`/JIT API) lands +here. + +## Key decision (amended 2026-06-04): respawn-on-cap (capped-persistent) + +Updates use **one persistent agent + a fresh `JITDylib` per edit**, with a +**background respawn every ~100 generations** (the cap). Not in-place +`write_mem` patching. This amends the original **respawn-first** decision +(below) after the generation-soak (`research/jit-poc/data/ +run-soak-20260604T024308Z.log`, previews-research): across 500 generations +latency stays flat (link/render medians ~0.4ms; conformance scans do not slow), +while RSS leaks linearly ~87KB/generation because `__swift5_*` metadata cannot +deregister. The cap bounds the leak at ~+9MB and amortizes the ~70ms respawn +warmup to ~0.7ms/edit. Net per-edit: **~167ms capped-persistent vs ~233ms +respawn-per-edit**. Respawn remains the cleanup mechanism — just on the cap, +not on every edit. The original rationale and evidence: + +- **Why respawn.** The Swift runtime has no deregister for `__swift5_proto` / + `__swift5_types` (Phase 1 SP0d-D). A long-lived agent that JIT-links a new + image per edit leaks metadata registrations permanently. Respawn (kill the + agent, spawn a fresh one that loads the new image) is the clean teardown for + exactly that leak. +- **Matches Apple.** The W3 empirical capture (design §2 "Per-edit address-list + capture — RESOLVED") found Apple respawns `XCPreviewAgent` for every edit kind + tested, with zero `write_mem`. The §2 patch-point table is the + static-analysis universe for a future in-place path, not the Phase 3 path. +- **State.** Preserving runtime `@State` across a structural edit is **not** a + Phase 3 goal (acceptance is latency-only). `DesignTimeStore` holds only + design-time literal values, not runtime `@State`, so literal-state continuity + is kept by re-seeding the store after respawn; runtime `@State` is lost on + structural edits, same as Apple. +- **Provenance of the alternative.** The §5/§6 in-place `write_mem` + + Begin/End/cancelUpdate handshake is the doc's *original* design, written + before the W3 respawn evidence landed (git: design doc `c8056de` precedes the + capture `76a7b34`). It is kept as a later, clearly-scoped chunk (P3.3), added + only if it earns its keep. Phase 2 already proved the `write_mem` publish + mechanism (P2.5). + +## Assumptions + +- The Phase 2 remote `JITSession` (socketpair + `SimpleRemoteEPC` + + `SwiftEntrySectionPlugin` + per-session agent) is reused unchanged in shape. +- `Compiler.compileObject(source:moduleName:extraFlags:)` already emits the `.o` + the JIT path needs. +- The macOS preview entry symbol is a `createPreviewView`-style `@_cdecl` + returning a retained `NSHostingView` (see `BridgeGenerator`, the daemon render + path in `PreviewsMacOS/HostApp.swift`). + +## Unknowns (each a verification gate) + +- **U-A:** can a spawned headless agent host `NSApplication` (`.accessory`) and + render an `NSHostingView` offscreen to a bitmap, the way the daemon does + today? (Gate for P3.1b.) +- **U-B (= Phase 1 U2):** does the Swift calling convention hold when we call a + real `View`-body entry that returns a retained pointer, marshaled onto the + agent's main thread, versus the trivial nullary functions Phase 2 ran? (Gate + for P3.1b.) +- **U-C:** does respawn within a live daemon session re-establish the agent and + re-seed `DesignTimeStore` fast enough to hit the design's <200ms structural + target on a small module? (Gate for P3.2/P3.4.) +- **U-D:** what is the minimal seam in `PreviewSession`/`HostApp` to route + structural edits to the JIT path without disturbing the literal-only + `DesignTimeStore` fast path? (Gate for P3.4.) + +## Subproblems and verification criteria + +### P3.1 — Agent SwiftUI render harness + +The big-jump risk. Split in two. + +#### P3.1a — run a real SwiftUI `View` body in the agent — DONE +The agent `dlopen`ed only Core/Concurrency/Foundation/Dispatch, so a JIT-linked +object referencing SwiftUI failed to materialize ("Symbols not found" for +`SwiftUI.View` / `SwiftUI.Text`). Added a SwiftUI `dlopen` +(`/System/Library/Frameworks/SwiftUI.framework/SwiftUI`, `RTLD_GLOBAL`; AppKit +pulled transitively by dyld) so the process-symbol generator resolves SwiftUI +symbols. Fixture `swiftui_probe.swift` is a trivial `View` whose `body` builds a +`Text`; the remote test links it in the agent, evaluates the body, returns 7. +AppKit-free and main-thread-free on purpose, to isolate one unknown. +- **Verify (met):** `runsSwiftUIViewBodyRemotely` returns 7. 26 tests green in + parallel, zero orphan agents. Commit `1fe37b3`. + +#### P3.1b-i — main-thread invoke surface + `NSHostingView` construction — DONE +Restructured the agent so the EPC server runs on a background thread and the +main thread runs a CoreFoundation run loop (`CFRunLoopRunInMode` after +`NSApplicationLoad()`), freeing the main thread for AppKit. Added an executor +wrapper `__previewsmcp_run_on_main` (a bootstrap symbol) that `dispatch_sync`s a +JIT'd function onto the main queue and returns its `int32_t`, mirroring Apple's +`run_program_on_main_thread`. Host side: `previewsmcp_jit_session_run_on_main` +fetches that bootstrap symbol and calls it via +`callSPSWrapper`; Swift `runOnMain(symbol:)`. Fixture +`hosting_probe.swift` builds an `NSHostingView` on the main thread and returns 1 +if its `fittingSize.width > 0`. +- **Verify (met):** `buildsHostingViewOnMainThreadRemotely` returns 1. Full + suite 27 tests green, 12/12 parallel runs clean, zero orphan agents. U-A + (Cocoa hosts in a headless agent) and U-B (Swift `View` entry on the main + thread) retired for the construction case. + +**Discovery (pre-existing race, fixed here):** moving the server off the main +thread changed timing enough to surface a latent data race. Native-target init +(`LLVMInitializeNativeTarget` / `…AsmPrinter`) ran under two separate +`std::call_once` flags, one in the in-process `makeJIT` and one in +`remote_session_create`. A concurrent first-time in-process + remote session +create then mutated LLVM's global `TargetRegistry` linked list from two threads, +corrupting it: `lookupTarget` either span forever (a hang, surfaced as agents +never torn down holding the test's inherited stdout pipe open, the P2.4 EOF +failure mode) or tripped a `SmallVector` assert. Fixed by unifying both paths +behind one shared `initNativeTargetOnce()` once-flag. Teardown is unchanged +(`session_destroy` resets the `LLJIT` then `SIGKILL`s the agent). + +#### P3.1b-ii — render the view to a bitmap — DONE +Fixture `render_probe.swift` renders a known-color SwiftUI view via +`ImageRenderer` (`MainActor.assumeIsolated`, since `ImageRenderer.cgImage` is +main-actor), samples the center pixel from the `CGImage`, and returns it packed +as `Int32`; the test drives it through `runOnMain` and asserts the channels are +red. `ImageRenderer` was chosen over an `NSHostingView`-in-window snapshot +because it rasterizes headless without a window. Retires the rendering half of +U-A: the agent rasterizes a JIT-linked SwiftUI view to pixels. + +**Discovery (the load-bearing one): U-A finally bit, and the slab is the fix.** +Bringing real SwiftUI into the agent surfaced three `EXC_BAD_ACCESS` crash modes, +all reading ~4 KB **past a mapped region** while the Swift/SwiftUI runtime walks +JIT'd type metadata (`swift_conformsToProtocol…`, AttributeGraph +`LayoutDescriptor` building, `NSHostingView` teardown). Root cause: the **remote** +path built `ObjectLinkingLayer(es)` with the **default per-allocation mmap** +memory manager, which scatters each section into its own page-rounded mmap; +reading one entry past a section lands in the unmapped gap right after it. This +is exactly Phase 2's deferred unknown **U-A** ("revisit only if a future large +object trips it") and the same class as Phase 1's unwind-slab gotcha. The six POC +scenarios were small enough to dodge it; SwiftUI's heavy metadata walking trips +it. **Fix (first attempt, REVERTED — see below):** a **contiguous slab** via the +shared-memory mapper (`SharedMemoryMapper` + `MapperJITLinkMemoryManager`); the +agent already hosted `ExecutorSharedMemoryMapperService`. With the slab the +section walks stay in-bounds and the suite was 40/40 green across parallel runs. + +#### P3.1b-iii — the shared-memory slab is macOS-incompatible; anonymous mapper instead — DONE +The shared-memory slab worked for ~80 runs, then **every** remote session began +failing with `Failed to materialize symbols { (, +{ ___mh_executable_header, ___dso_handle }) }: Permission denied`, and it did not +recover across a reboot. Root cause: the orc-rt `ExecutorSharedMemoryMapperService` +makes JIT memory executable with `mprotect(...PROT_EXEC)` on `MAP_SHARED` memory, +which **macOS denies (EACCES)**. This is standard macOS hardening, confirmed with +a standalone C program: `mmap` exec-from-start on shared memory is allowed, the +`mprotect` transition is not, in every context (unsigned, signed with +`allow-jit` / `allow-unsigned-executable-memory` / `get-task-allow`, under lldb). +The earlier 80 green runs were the permissive window; it closed mid-session and +stayed closed. **This was an expensive diagnosis** (it also masqueraded as a +"Compiler flags" problem during a confounded P3.2 attempt, since every variant +failed for the same shared-exec reason). NOTE: do not `pkill -9` loop hundreds of +JIT agents while debugging. + +How Xcode gets around it: Apple's `XCPreviewAgent` carries **no** JIT entitlement +(only `get-task-allow`, same as ours), and `XOJITExecutor` populates the agent's +**anonymous** executable memory over the wire (`___xojit_executor_write_mem`), +never shared memory. `mprotect`-exec on anonymous memory is permitted. + +**Fix (final):** replace the shared-memory slab with a **contiguous slab backed +by anonymous executor memory**, matching Apple. New code, no `third_party` patch: +- Agent: a mapper service (`__previewsmcp_anon_reserve` = anonymous `mmap`; + `__previewsmcp_anon_initialize` = `memcpy` the wire-transferred content + + `mprotect` + `InvalidateInstructionCache` + run finalize actions; + `deinitialize` = no-op; `release` = `munmap`). +- Host: `PreviewsAnonymousMapper` (a `MemoryMapper` that keeps a local working + buffer and ships segment content over the wire) driving + `MapperJITLinkMemoryManager` for one contiguous reservation per session. + +Two load-bearing details: (1) the agent must `InvalidateInstructionCache` after +writing exec segments, or ARM64 executes stale icache (intermittent garbage- +execution crash); (2) it **discards deallocation actions** — the JIT image is +process-lived in the respawn model (Phase 1 D3), so running JIT'd destructors at +teardown is unnecessary and was crashing (`mutex` EINVAL in a JIT'd dealloc +action). With both, U-A is fixed via anonymous contiguity and **the prior +post-result SwiftUI-teardown crashes also vanished**. +- **Verify (met):** suite **15/15 green** across parallel runs, zero agent + crashes, zero orphans. Commit `a8d5909`. + +**Two supporting fixes surfaced while diagnosing (asserts build, parallel +runner):** +- **Host target-init race (pre-existing).** `LLVMInitializeNativeTarget` ran under + two separate `call_once` flags (in-process `makeJIT` and `remote_session_create`). + A concurrent first-time in-process + remote create corrupted LLVM's global + `TargetRegistry` (hang via `lookupTarget` spin, or `SmallVector` assert). Unified + behind one `initNativeTargetOnce`. (Also serialized the in-process + `LLJIT::initialize` with a mutex; `ORCPlatformSupport::initialize` raced across + the shared in-process `LLJIT`.) +- **Agent teardown use-after-free (my regression from P3.1b-i).** The restructure + destroyed the `Server` on the background thread right after `waitForDisconnect`, + while the transport's listen thread was still in `handleDisconnect`. Fixed by + `std::_Exit(0)` on disconnect (the host `SIGKILL`s the agent anyway, so clean + unwinding has no value and is racy). + +**Verify (met):** `rendersViewToBitmapOnMainThreadRemotely` returns a red pixel; +suite 40/40 green across parallel runs, zero orphan agents. + +**Teardown crashes — RESOLVED by the anonymous mapper.** The shared-memory-slab +era left post-result teardown crashes (`NSHostingView` deinit, AttributeGraph +`Node::destroy`) that wrote crash reports without failing tests. Those are **gone** +with the anonymous mapper (P3.1b-iii): the contiguity removed the metadata +over-reads, and discarding deallocation actions removed the JIT'd-destructor +teardown path. The suite now runs with zero agent crash reports. + +### P3.2 — Hot update via agent respawn — DONE +Edit the preview body, recompile to a new `.o`, respawn the agent, the new +render reflects the change. No in-place patching. "Respawn" at the `JITSession` +layer = destroy the old session (kills its agent) + create a new one (spawns a +fresh agent); the daemon orchestration (FileWatcher → recompile → respawn) is +P3.4. +- **Test** (`CompilerObjectTests.rerendersAfterRecompileInFreshAgent`, mirrors + `reResolvesSymbolAfterRecompile`, **no new production code**): compile a + render-probe source set to color A (red) via `Compiler.compileObject`, link in + a remote session, `runOnMain` the render entry, assert the sampled pixel is + red; then compile the **same source edited to color B** (blue), link in a + **fresh** remote session, render, assert blue. Proves recompile → respawn → + re-render with the real `Compiler`. +- **Confounded finding RESOLVED — the default target works.** The earlier claim + that `Compiler.compileObject`'s default `-target arm64-apple-macosx14.0` fails + in the JIT (minos 14 → Swift back-deployment referencing + `___mh_executable_header`/`___dso_handle`) was an artifact of the macOS + shared-exec outage, where every compile variant failed for the unrelated shm + reason. Re-verified cleanly on the anonymous mapper: the default `Compiler` + target links **and renders** in the agent. **No host-OS `target:` option was + added** — `Compiler.compileObject` is unchanged. +- **Verify (met):** render v1 = red; recompile edited source; render v2 = blue, + in a fresh agent. Suite 29/29, 10/10 parallel runs green, zero orphan agents, + zero crash reports. Commit pending. + +### P3.3 — Begin/End/cancelUpdate handshake (§5/§6) — CONDITIONAL +Only if no-restart with `@State` preservation later earns its keep. Bracket an +update so an in-flight render never observes a half-applied edit; cancel reverts +from the redo log. New wire verbs. +- **Verify (planned):** BeginUpdate → writes → EndUpdate → UpdateComplete drives + a render; CancelUpdate mid-stream leaves the prior image rendering. + +### P3.4 — Daemon session-lifecycle integration (pulls in SP5) — IN PROGRESS +Route structural edits from `PreviewSession`/FileWatcher to the JIT path instead +of dylib rebuild; literal-only edits stay on `DesignTimeStore`. The richer +`SessionResolver`/JIT API (Phase 1 SP5) lands here. + +**Render-surface decision (agreed): agent-rendered bitmaps (model A).** The +structural path today renders **in the daemon process** (`loadPreview` dlopens +the dylib, calls `@_cdecl("createPreviewView")`, hosts the returned +`NSHostingView` in the daemon's window, snapshots via `cacheDisplay`, +HostApp.swift:80-153, :209). The JIT respawn path renders **inside the agent +process** instead, and the daemon serves `preview_snapshot` from the agent's +bitmap. Chosen over in-process JIT-in-daemon because in-process linking +reintroduces the per-edit Swift-metadata leak that respawn-first exists to avoid +(no `__swift5_proto`/`__swift5_types` deregister; Phase 1 SP0d-D). The cost of +model A is small here because **macOS is snapshot-only**: there is no macOS +touch/interaction tool to re-route (`preview_touch` is iOS-simulator-only and +already runs in its own on-device host-app process over a TCP socket via +`IOHIDEvent` injection; it is out of P3.4 scope). So only `preview_snapshot` +moves to the agent bitmap; interaction is untouched. + +**New unknowns under model A (both de-riskable in the JIT module before any +`HostApp` change):** +- **U-E:** the agent must return a **variable-size bitmap** to the host, but + `runOnMain` only returns an `Int32`. Needs a buffer-return surface (a new EPC + byte-returning wrapper, or the agent writes the bitmap to a host-supplied path + and the host reads the file). +- **U-F:** the agent must render the **real `Compiler` bridge** output (a + `createPreviewView`-style entry returning a retained `NSHostingView`), not a + self-contained `ImageRenderer` probe. + +**Chunking (de-risk first):** +- **P3.4a — agent bitmap-return surface (U-E) — DONE.** File transport chosen: + the agent renders and writes the bitmap to a host-supplied path, the host reads + it. Needs **zero new EPC/C++ wire code** — it reuses the `runOnMain` `Int32` + status surface and the existing bridge-source-templating pattern. Test + `CompilerObjectTests.rendersBitmapToFileFromAgent`: template a render source + with a unique temp PNG path, compile via `Compiler.compileObject`, link + remotely, `runOnMain` render-and-write, host reads + decodes the PNG, center + pixel is red. The EPC byte-return wrapper stays the fallback if file transport + proves inadequate (it won't for snapshots). *Verify (met):* 30/30 green, 3/3 + parallel runs, zero orphans, zero crash reports. Commit pending. +- **P3.4b — real bridge renders in the agent (U-F) — DONE.** Added the render + seam to `BridgeGenerator` (model A): `generateCombinedSource(renderOutputPath:)` + emits a nullary `@_cdecl("renderPreviewToFile")` for macOS that builds the + **same** `viewCode` as `createPreviewView`, rasterizes it headless via + `ImageRenderer`, and writes a PNG to the baked path. Nullary keeps it on the + `runOnMain` surface; the path is baked because the daemon recompiles per + structural edit. `createPreviewView` is untouched, so the in-daemon path is + undisturbed. Test `CompilerObjectTests.rendersRealBridgeToFileFromAgent` drives + a real combined bridge (DesignTimeStore + `__PreviewBridge` + thunk'd user + `#Preview`) through the agent and asserts the host-decoded PNG is green. No + extra agent `dlopen` was needed (Observation comes in transitively via SwiftUI). + *Verify (met):* 31/31 JIT green, 69/69 `BridgeGenerator` core green, zero + orphan agents. Commit pending. +- **P3.4c — daemon seam (U-D).** Route structural edits to + recompile → respawn → agent-render → serve `preview_snapshot`; literal path + unchanged. Split: + - **P3.4c-i — protocol seam + structural→agent snapshot.** Define a + `StructuralReloader` protocol in `PreviewsCore` (JIT-free); `PreviewsJITLink` + implements it; the executable composes them, injecting the real reloader only + when `jitEnabled`. **Chosen on layering merit, not to appease CI** (base owns + the abstraction, JIT module owns the mechanism, app wires them) — the `#if` + alternative was rejected as the actual workaround. As a side effect the + non-JIT build still compiles. A session's first `compile()` keeps the existing + in-daemon dylib + `NSHostingView`; the first **structural** edit switches the + session to agent-rendered and `preview_snapshot` serves the agent's PNG. + Three steps: + - **c-i-1 — protocol + `PreviewSession.compileObjectForJIT()` — DONE.** The + protocol is `renderObject(at:entrySymbol:)`, agnostic to + respawn-vs-capped-persistent (the impl picks). `compileObjectForJIT` emits a + render-bridge `.o` (baked PNG path) returning a `JITRenderBuild`. *Verify + (met):* `StructuralReloaderTests` (mock reloader) — the `.o` exports + `_renderPreviewToFile` and the plumbing routes; 305/305 `PreviewsCore` green; + Core stays JIT-free so the non-JIT build compiles by construction. + - **c-i-2 — real `JITStructuralReloader` in `PreviewsJITLink` — DONE.** + Implements the protocol over a remote `JITSession` (spawn agent → + `addObject` → `runOnMain(entrySymbol)`, throws on non-zero status); + respawn-per-edit via `JITSession` `deinit`. Required adding `PreviewsCore` + as a `PreviewsJITLink` dependency (the JIT module now implements a Core + protocol; also fixed a transitive `_SwiftSyntaxCShims` module error). Test + `JITStructuralReloaderTests` drives a real `compileObjectForJIT` `.o` through + the reloader and asserts the agent-written PNG is green. *Verify (met):* + 32/32 JIT green, 3/3 parallel, zero orphans, zero crash reports. + - **c-i-3 — host wiring — DONE (pending CI non-JIT check).** (a) `PreviewHost` + gained `structuralReloader: (any StructuralReloader)?` + an + `agentImagePaths` map; `jitStructuralReload(sessionID:session:)` compiles the + `.o`, calls `renderObject`, records the PNG; `watchFile`'s structural branch + prefers it when injected, else the existing dylib path. (b) + `MacOSPreviewHandle.snapshot` returns the agent PNG (via new + `Snapshot.encode(imageAt:format:)`, PNG passthrough / JPEG transcode) when + the session is agent-backed. (c) Composition root: one `#if PREVIEWSMCP_JIT` + in `PreviewsMCPApp` injects `JITStructuralReloader()`; `Package.swift` adds + `PreviewsJITLink` to `PreviewsCLI` and defines `PREVIEWSMCP_JIT` **only when + `jitEnabled`**. Daemon logic stays `#if`-free (Core protocol only); the one + conditional is at the app entry where composition belongs. *Verify (met):* + `PreviewHostJITReloadTests` (host records the agent image; nil reloader falls + through) + `MacOSPreviewHandleAgentSnapshotTests` (snapshot returns the agent + PNG); macOS 3 / engine 9 / JIT 32 green; full JIT `swift build` green. The + non-JIT build (`jitEnabled=false`) is verified by CI on push. + - **P3.4c-ii — literal-after-structural.** Once a session is agent-rendered the + view lives in the agent, so the in-daemon `DesignTimeStore` literal path can no + longer update it (`applyLiteralChanges` would no-op for lack of a loader). + - **Fallback — DONE.** `watchFile` gates the literal fast path on + `agentSnapshotPath(for:) == nil`, so an agent-backed session routes **any** + edit through the structural JIT reload. Correct but full-recompile. + - **Proper path (value file-transport, mirrors the PNG choice).** The agent + runs only nullary entries, so instead of calling the `designTimeSet*` setters + over the wire, the render bridge **seeds `DesignTimeStore` from a baked JSON + path** before rendering; a literal edit rewrites that JSON and re-renders the + **same `.o`** (no recompile; ~62ms relink+render vs 281ms). Caveat: with the + respawn-per-edit reloader each call still relinks; true ~10ms needs the + capped-persistent agent (re-seed + re-render in place). + - **c-ii-1 — DONE.** `BridgeGenerator.renderToFileEntryPoint` seeds + `DesignTimeStore.shared.values` from `designTimeValuesPath` (JSON) before + building the view; `compileObjectForJIT` bakes the path, writes the + literals' initial values (`writeDesignTimeValues`), and `JITRenderBuild` + gains `valuesPath` + `literals`. *Verify (met):* values JSON written with + the literals (`StructuralReloaderTests.writesDesignTimeValues`); the agent + render still produces the correct color through the seeded path; JIT 33 / + BridgeGenerator 69 / PreviewsCore 306 green. + - **c-ii-2 — DONE.** Test `literalRewriteReRendersSameObjectNoRecompile`: + compile a `Color(white: 0.2)` preview once, render (dark), rewrite the + white literal in the values JSON to 0.9, re-run `renderObject` on the + **same `.o`** → brightness flips dark→light, no second compile. + - **c-ii-3 — DONE.** `PreviewSession` caches the last `JITRenderBuild` + (`lastJITBuild`) and adds `applyLiteralValuesForJIT(_:)` (merge the changed + literals into the values JSON, return the build to re-render). `PreviewHost` + gains `jitLiteralReload(sessionID:session:changes:)`; `watchFile`'s literal + branch routes agent-backed sessions there (rewrite values + re-render, no + recompile) instead of the fallback full reload, while non-agent sessions + keep the in-daemon `DesignTimeStore` path. *Verify (met):* + `PreviewHostJITReloadTests.literalReloadRewritesValuesAndRecordsImage` + (values JSON updated, image recorded) + the c-ii-2 pixel flip; PreviewsCore + 306 / macOS 4 / engine 9 / JIT 34 green. +- **P3.4d — latency (U-C) — DONE (measured; compile-bound).** Integrated + `compileObjectForJIT()` + `JITStructuralReloader.renderObject()` on a small + module, steady-state (warm module cache), respawn-per-edit: + **compile ≈ 218ms, link+respawn+agent-render ≈ 62ms, total ≈ 281ms** + (`StructuralReloadLatencyTests`, machine-specific but indicative). The + render/respawn half is well within budget; the **whole-module compile dominates + and is the entire gap** to the <200ms target — exactly the "recompile-narrowing + gaps" finding: respawn + JIT-link removes the *relink*, not the *compile*. + Closing it needs the stable-module/editable-unit split (single-file `@testable` + compile against prebuilt `.swiftmodule`s; the manager's W4-W7 research measured + ~0.14s relink at 1000 files), which is the deferred compile-side lever, not a + JIT-executor change. Capped-persistent (the ratified executor shape) further + trims the respawn warmup from the 62ms (~70ms amortized to ~0.7ms/edit in the + soak), but compile still gates until narrowed. + +**Deferred infra (separate from architecture): JIT-in-CI.** CI skips the JIT +path only because the targets need `third_party/llvm-build` (multi-GB prebuilt +LLVM + orc-rt) that CI does not have; the `jitEnabled` gate makes the non-JIT +build green. Making CI **run** the JIT tests means caching or building that +artifact once and reusing it — a self-contained infra task that does **not** +shape the daemon design. Deferred (Phase 3 infra / Phase 4); tracked here. Until +then JIT tests stay local-only and the protocol seam keeps non-JIT CI green. + +- **Verify (planned):** an `examples/` project: a literal edit hot-reloads via + `DesignTimeStore` (existing path, ~10ms); a structural edit reloads via the + JIT path (respawn), same daemon session, no daemon restart. + +### P3.5 — Plan doc + PR — IN PROGRESS +This document, mirroring the Phase 1/2 plan docs, updated as work lands. PR #190 +(draft), watched to green. CI does not build the JIT targets, so JIT tests are +local-only; the non-JIT build must stay green. + +## Recompile-narrowing gaps (uncaptured — these gate the latency target) + +P3.4 routes structural edits to the JIT respawn path, but as planned it still +recompiles the **whole module** every edit (`PreviewSession.compile()` passes the +full `buildContext.sourceFiles` to swiftc; `Compiler.swift:141`). Respawn + +JIT-link removes the full *relink*, not the full *compile*. On a 1000-file module +the compile dominates, so the design's <200ms target is unreachable until the +recompile is narrowed to the changed file. The design names the prerequisite +(`prompts/jit-executor-design.md:260`, "incremental swiftc") but neither piece +below is specified or built. + +### G1 — Incremental compile (refined by W4) +**Missing.** A way to re-emit only the edited file's object. W4 +(`research/scripts/analysis/w4-compile-side.md`) captured how Apple does this and +it splits by module boundary, because Swift compiles a module as a unit and a +module cannot import itself: +- **Cross-module (size-independent).** Dependency modules are built once to a + `.swiftmodule` and consumed as prebuilt inputs (`-experimental-emit-module- + separately`, `-I` + explicit `.swiftmodule`). An edit never re-parses them. + This is the real "compile against a prebuilt module" shape. +- **Same module (size-dependent).** There is no prebuilt-interface trick. The + frontend is handed the **full filelist** every edit to parse and type-check; + `-incremental` + the `-output-file-map` then restrict the back end + (SILgen/IRGen) to the changed file's `.o`. So per-edit cost = parse/bind all N + files + codegen one file. Apple measured ~1.3s on 61 files; this grows with N. + An edit to a referenced public declaration also re-emits its dependents, so the + JIT must relink the **set** of changed objects, not always one. + +`Compiler.compileObject` takes one source today and does neither: no persistent +`-incremental`/output-file-map state, no prebuilt-`.swiftmodule` inputs. Two +implementation paths were considered: (a) adopt swiftc incremental honestly +(persistent per-session build dir, full filelist, re-emit changed objects); +(b) the stable-module/editable-unit split — put the editable preview in a +**separate** module that imports the bulk as a prebuilt `.swiftmodule`. + +**Verdict (W5, `research/scripts/analysis/w5-scaling.md`, CLOSED): path (b) is +mandatory.** Path (a) does not scale. Same-module incremental re-emits only 1 +object but pays whole-module front-end (parse+bind all N files) every edit: +0.65 / 1.41 / 2.82 / 6.06 s at N = 100 / 250 / 500 / 1000 (~45ms + 6ms/file), so +the 200ms budget breaks at ~25 files — a conservative lower bound on trivial +bodies, real SwiftUI breaks sooner. The split holds per-edit time **flat +~0.144s** from N=100 to 1000 (bulk built once, not per edit). This is what Apple +already does (W4 thunk: one `-primary-file` against prebuilt `.swiftmodule`s). +Fan-out (W5 M2): a body edit is **1 object** regardless of dependents; an +interface edit to a decl referenced by K files is **1+K** objects — the JIT must +budget relinking that set. The auto-split mechanism is W7; W5 is its +justification. +- **Verify (met by W5; split feasibility met by W7):** the same-module-vs-split + curves above. W7 (`research/scripts/analysis/w7-autosplit.md`, CLOSED) proves + the split is **feasible for the common case**: an `internal` SwiftUI view + compiles in a separate editable unit via `@testable import` against a stable + module built `-enable-testing` (free — previews are Debug), edit→relink flat + ~0.14s at stable N=200 and 1000. Four break-cases: (1) preview touches + `private`/`fileprivate` bulk decls (invisible — promote to `internal` or + co-locate); (2) `@_spi` decls (need a generated matching `@_spi` import); + (3) editing the stable module's **own interface** (re-emits the `.swiftmodule` + ⇒ W5 same-module cost + 1+K relink — only preview-side edits stay flat); + (4) `package` decls (need a shared `-package-name`). Both soft spots are now + closed by the **integrated POC — PASSED** (`cfe9bda`, + `research/jit-poc/build-split.sh`): the full chain (split → `@testable` + single-file compile → JIT-link → render) proven end-to-end, v1 renders red and + edited v2 renders blue, so S2 symbol override is demonstrated, not cited. + Numbers: edit→pixels **~233ms** with respawn semantics (compile ~165ms + + spawn/dlopen/link/render ~69ms); a **persistent agent** re-linking each + generation into a fresh `JITDylib` pays ~2ms after compile ⇒ **~167ms**, under + the 200ms budget. Load-bearing implementation findings: the agent **must** call + `LLJIT::initialize(JD)` per generation (runs `jit_dlopen`, registers + `__swift5_*` metadata — SwiftUI conformance lookups segfault without it), and + the ObjCSelrefPlugin + `ExecutorNativePlatform` stack is required (SwiftUI is + selref-heavy). Break-case (1) is **impossible by construction at file + granularity** (a moved file's `private` decls move with it; zero + `private`/`fileprivate` decls in any preview-bearing `examples/` file) — rule: + the executor always splits at file granularity. + +### G2 — A file-identifying FileWatcher +**Missing.** `FileWatcher` signals only "something in the watched set changed", +not which file (`FileWatcher.swift`). G1 needs the changed path to pick the file +to recompile. The watch scope is already ~"project sources minus dependencies" +(deps arrive prebuilt via `-I`/`-L`), so the gap is identity, not scope. +- **Verify:** editing file X in a multi-file target delivers X's path to the + recompile; an edit to unrelated file Y recompiles Y, not X. + +### Capped-persistent reloader (#191 item 2) — IN PROGRESS +One agent serves many edits, each in a fresh `JITDylib`, respawning at a cap. +- **Chunk 1 — DONE (`1392dd3`).** `previewsmcp_jit_session_new_generation` + (fresh JD on the same LLJIT + reset `initialized` so the next run re-runs + `LLJIT::initialize`), `JITSession.newGeneration`; `CappedPersistentTests` reuses + one agent across 5 generations rendering distinct colors. +- **Chunk 2 — DONE (commit pending).** `JITStructuralReloader` is now an `actor` + holding a persistent `JITSession` + a generation counter; `nextSession()` calls + `newGeneration` under the cap and respawns (replacing the session, whose `deinit` + kills the old agent) at `generationCap` (default 100). All reloader/latency/E2E + tests pass unchanged; `reloaderRespawnsAtGenerationCap` (cap=2, 5 renders) and + `splitRendersRealPreviewAcrossGenerations` (the real Summary preview rendered + twice → gen2 re-links ToDoExtras/Lottie/builtins) are green, so U2 (duplicate dep + registration) does not crash at low generation counts. +- **Chunk 2b — DONE (commit pending).** The duplicate-class warning had two causes, + both fixed. (i) Distinct structural compiles reused the same editable module name; + fix = a **unique `-module-name` per compile** (`PreviewEdit__` / + `_`, token = `PreviewSession.uniqueModuleToken()` = `"g"` + a + fresh UUID hex prefix; the stable module stays `ctx.moduleName` for `@testable`). + (ii) The **literal** re-render re-linked the *same* object into a new generation; + fix = the reloader re-runs the entry **in place** when `objectPath == lastObjectPath` + (no `newGeneration`, no re-link) — also the design's "literal path is in-place ~10ms". + Verified: `editableModuleNameIsUniquePerCompile` (two compiles → disjoint `_$s… + DesignTimeStore` symbols) + the full JIT suite renders across generations with zero + `objc[...] implemented in both` warnings. + - **Layout-sensitive crash (worth knowing).** The token is computed statelessly *on + purpose*. The first attempt stored a `jitCompileCounter` property on the + `PreviewSession` actor; adding that stored field shifted the actor's layout and + deterministically surfaced a latent `EXC_ARM_DA_ALIGN` (SIGBUS) in the Swift async + main-queue drain at process exit of `PreviewHostJITReloadTests` (a mock-reloader + test that never runs JIT'd code). A trivial whitespace recompile did NOT trigger it, + so it is layout-sensitivity surfacing a pre-existing UAF/over-release near + PreviewSession's async teardown, not the counter logic. The stateless UUID token adds + no stored property and sidesteps it (and is globally unique, fixing a cross-session + `g1` collision the per-session counter would have had). The underlying latent + corruption is unfixed and uncharacterized — flagged for a future pass. + +### JIT-in-CI (#191 item 4) — IN PROGRESS (commit pending) +CI skipped the JIT tests because the targets are gated on `third_party/llvm-build` +(+ `llvm-build-rt` + the `llvm-project` source headers), which the runners lack. +- **`cache-warm.yml` `warm-jit-cache` job** builds the pinned Swift-fork LLVM via + `scripts/build-jit-llvm.sh` and caches the three `third_party/` dirs, keyed on + `hashFiles('scripts/build-jit-llvm.sh')` (changes only when the recipe / pinned + SHA changes). Weekly cron + `workflow_dispatch` keep it warm; the build step + skips on a cache hit (guard on the orc archive). +- **`ci.yml` `jit-tests` job** (gated on `changes.src`) restores that cache, + **builds from source on a miss** (90m job timeout absorbs it), `swift build`s + the package + the SPM example, then runs `swift test --filter PreviewsJITLinkTests`. +- **First-run / rollout:** the very first PR run cache-misses and pays the full + libLLVM build (~tens of min). After merge, dispatch `warm-jit-cache` once so + `main`'s cache is warm; subsequent PRs just restore. Verify by watching the + `jit-tests` job on the PR. Risk: the JIT suite occasionally `SIGABRT`s under heavy + agent spawning (flaky); if it bites CI, add a retry or split the heavy E2E out. + +### Model mismatch — RESOLVED (by W5+W7) +The design's fast path assumes edits land in the **editable/preview layer** on +top of a rarely-rebuilt **stable module**; an edit to an arbitrary stable-module +file falls to the slower path. Evidence settled the choice: target +**preview-layer edits** for the flat ~0.14s fast path (W7), and accept that +edits to the stable module's own interface fall back to W5 same-module cost +(+1+K relink) — the rare hot-path case. "Any edited file reloads sub-second" is +not achievable at scale (W5: whole-module front-end breaks 200ms at ~25 files) +and is no longer a goal. + +### Apple-evidence status (W3/W4/W5 — CLOSED) +W3 verified Apple's respawn-only *dispatch* (8 edit kinds, zero `write_mem`; +`w3-empirical-capture.md`). W4 (`w4-compile-side.md`, `w4-thunk-argv.txt`) closes +the compile side: Apple recompiles **one file**, confirmed by object-mtime diff +(1 of 61) **and a live capture of the preview thunk `swift-frontend` argv** — +exactly one `-primary-file` (the edited file), `-vfsoverlay` thunk substitution, +prebuilt-module reuse via `-disable-implicit-swift-modules` + +`-explicit-swift-module-map-file` + `-I` (the G1 cross-module shape, confirmed). +W5 (`w5-scaling.md`) measured the scaling and **makes the stable-module/editable- +unit split mandatory** (numbers in G1). + +**Mechanism correction (W4):** Apple does **not** use Swift dynamic replacement +for previews on Xcode 26.x — no `-enable-implicit-dynamic`, zero `__swift5_replace` +sections in thunk objects. It recompiles the edited file into a fresh +`PreviewRegistry` entry and **respawns** (PreviewRegistry-reentry, matching W3 +respawn-only dispatch). This corrects the prior `project_jit_dynamic_replacement` +assumption; our respawn-first decision is unaffected (only the rationale changes). + +**Bonus (W4):** Apple's literal fast-path is **data injection** +(`__designTimeString`/`Integer` + fallback), not recompilation — the same idea as +this project's `DesignTimeStore`. **W7 — CLOSED:** +auto-split is feasible for the common case (matrix + break-cases in G1). +**Integrated POC — PASSED** (`cfe9bda`): the full split → `@testable` → +JIT-link → render chain is proven; numbers and findings in G1. +**Generation-soak — DONE, decision ratified.** 500 generations, one persistent +host, fresh JD each: latency FLAT (link ~0.37-0.47ms, render ~0.33-0.43ms +medians across all windows; `swift_conformsToProtocol` does not slow), RSS +linear ~87KB/generation (unreclaimable — `__swift5_*` cannot deregister), zero +mprotect/MAP_JIT failures, zero wrong pixels. Verdict: **capped-persistent** +(see "Key decision", amended to respawn-on-cap). + +**W6 — CLOSED** (`research/scripts/analysis/w6-designtime.md`). Two results. +*Canvas-is-split (refines G1):* Apple's canvas thunk compile has **no** +`-filelist`/`-incremental`/batch-mode — it is single `-primary-file` + +`-vfsoverlay` + explicit module map, i.e. **already the W5/W7 split shape**, so +the canvas latency number is the split number, not the same-module number. +*Injection lifecycle:* `#salt_n` IDs generated at thunk compile → runtime value +table keyed by ID, read via `__designTime{String,Integer,Float,Boolean}` → on a +literal edit, re-inject by ID via the `PreviewsInjection` `EntryPoint` +`UpdatePayload` stream — **no recompile, no respawn**. Structural edits take +`PerformFirstJITLink`/`JITLinkEntrypoint` instead. Our `DesignTimeStore` +(`@Observable` + `@_cdecl designTimeSet*`) is a faithful mirror. The boundary is +`LiteralDiffer` skeleton-equality, including the UIKit-region downgrade (#160). +Minor open: Apple's `UpdatePayload` wire format read from symbol names, not a +decoded live XPC dump. + +**Executor edit-tier model (research arc complete — W3-W7 all CLOSED):** +1. **Literal-only SwiftUI edit** → value push by ID into the running agent + (`DesignTimeStore` path) — no compile, no respawn. Cheapest. +2. **Structural edit** → W7 split compile (one file vs prebuilt + `.swiftmodule`s) + JIT-link into a fresh `JITDylib` under capped-persistent + (respawn-on-cap). ~167ms. +3. **UIKit-region literal edit** → tier 2 (UIKit captures the value once and + never observes, #160). +Classify edits exactly as `LiteralDiffer` does (skeleton or literal-count change +⇒ tier 2; literal value change in a SwiftUI region ⇒ tier 1). + +## Phase 3 status: CORE COMPLETE (P3.1–P3.4 landed; P3.3 deferred, examples E2E pending) + +P3.1, P3.2, and **P3.4 (a/b/c-i/c-ii/d)** are done on branch +`jit-phase3-session-integration` (PR #190), CI green (non-JIT build + lint + +ios-tests). The agent links a real SwiftUI preview and renders it to a bitmap on +its main thread over a contiguous **anonymous** executor-memory slab (P3.1). +P3.2 proved recompile → respawn → re-render through the real `Compiler`. P3.4 +wired it into the daemon behind a `StructuralReloader` protocol (JIT-free Core, +implemented in `PreviewsJITLink`, injected at the executable via one +`#if PREVIEWSMCP_JIT`): structural edits render in the agent and `preview_snapshot` +serves the agent PNG (file transport); literal edits on an agent-backed session +rewrite a baked design-time-values JSON and re-render the **same `.o`** with no +recompile (value file-transport). Measured structural latency **≈281ms** +(compile ≈218ms + link/respawn/render ≈62ms) — the render half is within budget, +the **whole-module compile is the whole gap**. + +**Remaining (next phase, see the new follow-up issue):** (1) **recompile-narrowing** +— the stable-module/editable-unit split (single-file `@testable` compile against +prebuilt `.swiftmodule`s) to get under the <200ms target; this is the dominant +lever and is coupled to the compile-strategy research (W4–W7 on +`previews-research`). (2) **capped-persistent reloader** — replace the +respawn-per-edit `JITStructuralReloader` body with one persistent agent + +fresh `JITDylib` per edit + respawn-on-cap (~100), to make the literal path truly +in-place (~10ms) without touching the protocol. (3) the `examples/` E2E verify. +(4) JIT-in-CI infra (cache the prebuilt LLVM so CI runs the JIT tests). P3.3 +(in-place `write_mem` + Begin/End/cancelUpdate) stays conditional. + +## Scope boundaries + +- **Phase 3 (this branch):** P3.1–P3.5. Respawn-first; local unix-socket + transport (inherited from Phase 2). +- **Deferred Phase 4+:** in-place `write_mem` fast path + the handshake (P3.3 if + not pulled in); large-module scaling; XPC/gRPC transports; iOS device agent; + LLVM bundling; crash recovery; multi-session. + +## Immediate next step (resume pointer for a fresh session) + +**State:** P3.1, P3.2, P3.4 (a/b/c-i/c-ii/d) DONE on PR #190, CI green. Branch +`jit-phase3-session-integration`, tip **`70e8f0d`**. Working tree clean. Run JIT +tests with `swift test --filter PreviewsJITLinkTests` (builds `PreviewAgent`; +expect 34 green, zero orphan `PreviewAgent`). The seam/host tests are non-JIT: +`swift test --filter "StructuralReloaderTests|PreviewHostJITReloadTests|MacOSPreviewHandleAgentSnapshotTests"`. +If `third_party/llvm-build` is missing, run the bootstrap skill with `--jit` (do +NOT rebuild LLVM if it exists). **Before committing edited Swift, format with +`swift-format format -i --recursive `** — plain `-i` uses defaults and +fails CI's `swift-format lint --strict --recursive` (see +[[project_swiftformat_recursive]]). + +**Key files (P3.4 seam):** `Sources/PreviewsCore/StructuralReloader.swift` +(protocol), `PreviewSession.compileObjectForJIT()` + `applyLiteralValuesForJIT()` ++ `JITRenderBuild`, `BridgeGenerator.renderToFileEntryPoint` (PNG + JSON seed), +`Sources/PreviewsJITLink/JITStructuralReloader.swift` (impl, **respawn-per-edit** +— this is the body to swap for capped-persistent), `PreviewHost.jitStructuralReload`/ +`jitLiteralReload`/`agentImagePaths` + `watchFile` routing, +`MacOSPreviewHandle.snapshot` reroute, `PreviewsMCPApp.swift` `#if PREVIEWSMCP_JIT` +injection + `Package.swift` `jitCLIDependencies`/`jitCLISwiftSettings`. + +**Next (next phase / follow-up issue), in priority order:** +1. **Recompile-narrowing** (the <200ms lever; compile is 218ms of 281ms): wire + `compileObjectForJIT` to the stable-module/editable-unit split — single-file + `@testable` compile against prebuilt `.swiftmodule`s (manager's W4–W7 research + on `previews-research`, ~0.14s relink at 1000 files). This is the dominant work. +2. **Capped-persistent reloader**: swap `JITStructuralReloader`'s respawn-per-edit + body for one persistent agent + fresh `JITDylib`/edit + respawn-on-cap (~100); + the protocol does not change. Makes the literal path truly in-place (~10ms). + Note: the agent must call `LLJIT::initialize` per generation (registers + `__swift5_*`; segfaults without) — satisfied under respawn, must be added for + persistent. +3. `examples/` E2E verify (literal ~10ms via `DesignTimeStore`; structural via JIT + respawn; same daemon session, no restart). +4. JIT-in-CI infra (cache prebuilt LLVM/orc-rt so CI runs the JIT tests). +P3.3 (in-place `write_mem` + Begin/End/cancelUpdate handshake) stays conditional. + +**Pitfalls carried forward:** +- macOS denies `mprotect`-to-exec on `MAP_SHARED` memory (no entitlement / reboot + helps). The slab is anonymous now; never reintroduce the shared-memory mapper. +- When debugging, do NOT `pkill -9` loop hundreds of JIT agents. Use modest + verification loops (10–15 runs). Clean session teardown reclaims memory; only + kill stray agents at the end. +- The agent's anonymous mapper must `InvalidateInstructionCache` on exec segments + and discards deallocation actions (process-lived image, D3). Don't re-add + dealloc-action running. +- macOS crash reports for the agent live in `~/Library/Logs/DiagnosticReports/ + PreviewAgent-*.ips` (parseable JSON after the first line); the faulting-thread + backtrace is the fastest way to localize an agent crash. +- CI `lint` is `swift-format lint --strict --recursive`. Format edited Swift with + `swift-format format -i --recursive ` (plain `-i` uses tool defaults and + leaves files CI rejects; a file list after `--recursive` only formats the first + arg, so loop). build-and-test can pass while `lint` fails — check all four jobs. + +## P4.1 recompile-narrowing (#191 item 1) — IN PROGRESS + +**Decision: Fork B (project/Tier-2 split), file-granularity, hot-leaf heuristic.** +The split puts the edited preview file (the "hot" file) in a single editable unit +that `@testable import`s a stable module built from the rest of the project's +sources. The W5/W7 lever: the per-edit compile recompiles only the hot file, so it +stays flat (~158ms measured) while the whole-module baseline grows with module +size. Sound only when the hot file is a **leaf** the bulk does not depend on +(preview-layer edits fast; edits to the stable module's own interface fall back to +the slow whole-module path — the rare hot-path case, accepted per W5/W7). + +**The hot-leaf heuristic.** The edited file cannot live in both the stable module +and the editable unit (duplicate symbols at JIT-link). So per edit: pick the hot +file, build `stable = projectSources − hotFile` once with `-enable-testing`, +compile the hot file as the editable unit `@testable import`ing it, JIT-link +stable.o + editable.o. Repeated edits to the **same** hot file reuse the cached +stable module → flat fast path. An edit to a **different** file changes which file +is hot → rebuild the stable module and reset. Fan-out (W5 M2): a body edit is 1 +editable object; an interface edit to a decl K files reference is 1+K objects. + +**Done (P4.1-a, commit 34613af):** `Compiler.emitStableModule` (whole-module `.o` + +`-enable-testing .swiftmodule`) + `Tests/PreviewsJITLinkTests/SplitCompileTests.swift` +proving the mechanism at the unit layer (cross-module `@testable` render, stable +reuse across edits, flat 180ms vs 312ms whole at N=24). The editable unit reuses +`Compiler.compileObject(..., extraFlags: ["-I", modulesDir])`. + +**Avenues / order (recommendation: B1→B2→B3, then G2):** +- **B1 — DONE (commit pending).** `PreviewSession.compileObjectForJIT()` now splits + in Tier-2 mode: hot file = `self.sourceFile`, stable bulk = `ctx.sourceFiles` + (the build systems already exclude the preview file — SPM `getOtherSourceFiles`, + confirmed). The editable unit is a separate module `PreviewEdit_` that + `@testable import`s the stable module (`generateCombinedSource(stableModuleImport:)`), + compiled with `-I ` + `ctx.compilerFlags`. `JITRenderBuild` + gained `supportObjectPaths` (the stable `.o`, linked before the editable object); + standalone leaves it empty so the daemon path is unchanged. + `Tests/PreviewsJITLinkTests/PreviewSessionSplitTests.swift` proves it: hot file + consumes a bulk `Palette` cross-module, renders red, structural edit re-renders + blue. Leaf assumption holds for the fixture (no bulk file references the hot view). +- **B2 — DONE (commit pending).** `PreviewSession` caches the stable module + (`cachedStableModule`) keyed by the bulk files' modification dates. Repeated edits + to the hot file reuse it (flat fast path); a bulk-file change invalidates and + rebuilds. Within a session the hot file is fixed (`self.sourceFile`), so the + "different hot file" case is a different session, not handled here. Verified by + `stableModuleCachedAcrossHotEditsAndInvalidatedByBulkChange` (same + `supportObjectPaths` across two hot edits; different after touching a bulk file). +- **B3 — DONE (commit pending).** `StructuralReloader.renderObject` gained a + `supportObjectPaths: [URL]` parameter; `JITStructuralReloader` adds those objects + before the editable object, so the agent links stable.o then editable.o. The daemon + callers (`HostApp.jitStructuralReload`/`jitLiteralReload`) pass + `build.supportObjectPaths`. Standalone passes `[]` (unchanged). Verified by + `reloaderRendersSplitBuildThroughBothObjects` (a project-mode split build renders + red through the real reloader). Test mocks updated for the new signature. +- **examples E2E (#191 item 3) — PARTIAL (commit pending).** + `Tests/PreviewsJITLinkTests/ExamplesSplitE2ETests.swift` drives a real + `SPMBuildSystem` `BuildContext` for `examples/spm` (`ToDo` target). PASSING: the + split **compiles** a real cross-file preview (`Summary.swift`, references + same-module `Item` + sibling `ToDoExtras` package-scoped decls) — real + `-I`/`-package-name`/`@testable` flags flow correctly and both objects emit; the + literal path classifies a real preview (`BadgePreview.swift`) and reuses the same + object. BLOCKED on G3: the in-agent **render** is `.disabled` (see G3). +- **G3 — JIT agent must load the target's full link-dependency closure (DISCOVERED by + the E2E).** The non-JIT Tier-2 path links a dylib with `-L/-l` (+ `-F/-framework`) so + dyld + autolink resolve sibling/cross-package/binary deps; the JIT path emits `.o`s and + the agent JIT-links only our stable.o + editable.o, so dep symbols are unresolved at + JIT-link time (materialization fails; the agent then `SIGABRT`s on a dangling + `SymbolStringPool` — a secondary teardown robustness bug). Note the split makes this + unavoidable: the **stable module bundles every other target file**, so even a + dep-free hot file pulls the whole target's deps (e.g. editing `Summary.swift` still + pulls `Lottie` via `ToDoView.swift`). Three layers, peeled by the E2E: + - **G3-a — static dependency archives — DONE (commit pending).** `SPMBuildSystem` + archives sibling/cross-package targets into `lib.a`. Added + `previewsmcp_jit_session_add_archive` → `StaticLibraryDefinitionGenerator::Load` + on the session JD (lazy archive linking), Swift `JITSession.addArchive`, + `JITRenderBuild.archivePaths` (parsed from `ctx.compilerFlags` `-L`/`-l` by + `PreviewSession.dependencyArchives`), and the reloader adds archives before objects + (`renderObject(at:supportObjectPaths:archivePaths:entrySymbol:)`). Verified by + `ArchiveLoadingTests.resolvesSymbolFromStaticArchiveInAgent` (a JIT object resolves + a symbol from a `libtool` archive in the agent) — and the real E2E now resolves + `ToDoExtras`/`LocalDep`. + - **G3-b — binary frameworks — DONE (commit pending).** `-F/-framework` deps (e.g. + `Lottie.framework`) are binary dylibs the agent `dlopen`s. Added + `previewsmcp_jit_session_add_dylib` → `EPCDynamicLibrarySearchGenerator::Load` + (loads the lib in the agent over EPC), Swift `JITSession.addDylib`, + `JITRenderBuild.dylibPaths` (parsed from `-F`/`-framework` to + `/.framework/` by `PreviewSession.dependencyDylibs`), reloader + adds dylibs before archives/objects (`renderObject(...dylibPaths...)`). Verified by + `ArchiveLoadingTests.resolvesSymbolFromDylibInAgent`; in the real E2E the Lottie + symbols now resolve, leaving **only** `___isPlatformVersionAtLeast` (G3-c). + - **G3-c — compiler-rt builtins — DONE (commit pending).** `___isPlatformVersionAtLeast` + (emitted by `#available`) is a defined symbol in the toolchain's `libclang_rt.osx.a`. + `Toolchain.compilerRuntimeArchivePath()` (`xcrun clang -print-runtime-dir` + + `libclang_rt.osx.a`) locates it; the split branch appends it to `archivePaths`, so it + loads via the G3-a archive mechanism (lazy, pulls only the referenced builtins). + **G3 COMPLETE — a real Tier-2 preview renders end to end.** + `ExamplesSplitE2ETests.splitRendersRealPreviewInAgent` is enabled and renders + `Summary.swift` (with `ToDoExtras` + `Lottie` deps) to a non-empty PNG. The earlier + `NSForwarding ... JSONObjectWithData` abort was teardown noise after the failing link; + it clears once the link succeeds. +- **G2 (deferred, separable)** — `FileWatcher` delivers the **changed path**, not + just "something changed". Feeds `hotFile` so the live daemon picks the hot file + itself. Verify: editing file X delivers X's path; editing Y recompiles Y not X. +- **Fork A (not chosen, fallback)** — standalone single-file split where the stable + module is only the `DesignTimeStore` + `PreviewBridge` boilerplate. Modest win + (boilerplate is tiny); kept as a note only. + +**Open questions for B1+:** (1) editable unit is a **separate** module name that +`@testable import`s the stable module — confirm no bulk file references the hot +view (leaf assumption) in the `examples/` projects; (2) where the daemon currently +gets `buildContext.sourceFiles` and whether the edited file is reliably in that +set; (3) the existing `compileObjectForJIT` standalone path stays as the no-build- +context fallback (stable module = boilerplate or skip split).