From 1fe37b35c5f2ea262c8ffbd305e7286de6524f5b Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Tue, 2 Jun 2026 12:01:31 -0400 Subject: [PATCH 01/46] JIT executor Phase 3 (P3.1a): run a real SwiftUI View body in the agent (#189) The agent only dlopened Core/Concurrency/Foundation/Dispatch, so a JIT-linked object referencing SwiftUI failed to materialize ("Symbols not found" for SwiftUI.View/Text). Load SwiftUI in the agent so its process-symbol generator resolves SwiftUI symbols; AppKit is pulled transitively by dyld. Adds swiftui_probe.swift (a trivial View whose body builds a Text) and a remote test that links it in the agent and evaluates the body, returning 7. Isolates one unknown: SwiftUI links and loads in the agent. AppKit-free and main-thread- free on purpose; offscreen rendering (NSHostingView, main-thread marshaling) is P3.1b. 26 tests green, zero orphan agents. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/PreviewAgent/main.cpp | 2 ++ .../Fixtures/swiftui_probe.swift | 15 +++++++++++++++ .../PreviewsJITLinkTests.swift | 8 ++++++++ 3 files changed, 25 insertions(+) create mode 100644 Tests/PreviewsJITLinkTests/Fixtures/swiftui_probe.swift diff --git a/Sources/PreviewAgent/main.cpp b/Sources/PreviewAgent/main.cpp index a125c05..673eb7b 100644 --- a/Sources/PreviewAgent/main.cpp +++ b/Sources/PreviewAgent/main.cpp @@ -90,6 +90,8 @@ int main(int argc, char *argv[]) { 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/SwiftUI.framework/SwiftUI", + RTLD_NOW | RTLD_GLOBAL); if (argc != 2) printErrorAndExit("expected exactly one argument"); 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/PreviewsJITLinkTests.swift b/Tests/PreviewsJITLinkTests/PreviewsJITLinkTests.swift index 1f1267a..2f4669c 100644 --- a/Tests/PreviewsJITLinkTests/PreviewsJITLinkTests.swift +++ b/Tests/PreviewsJITLinkTests/PreviewsJITLinkTests.swift @@ -91,6 +91,14 @@ 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 publishesNewAddressIntoSlotRemotely() throws { let object = try FixtureSupport.compile("patch_slot.c") let session = try JITSession(remoteAgentPath: JITSession.bundledAgentPath()) From 8e4fa0821a64e543a4685a2551d4151a2df1d55e Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Tue, 2 Jun 2026 12:08:26 -0400 Subject: [PATCH 02/46] Add docs/jit-executor-phase3-plan.md (Phase 3 living plan) (#189) Mirrors the Phase 1/2 plan docs. Records the respawn-first decision and its rationale, the unknowns as verification gates, the P3.1-P3.5 chunking, and P3.1a as done. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/jit-executor-phase3-plan.md | 152 +++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 docs/jit-executor-phase3-plan.md diff --git a/docs/jit-executor-phase3-plan.md b/docs/jit-executor-phase3-plan.md new file mode 100644 index 0000000..8262b5b --- /dev/null +++ b/docs/jit-executor-phase3-plan.md @@ -0,0 +1,152 @@ +# 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 (agreed): respawn-first + +Updates use the **agent-respawn** model, not in-place `write_mem` patching. + +- **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 — render the view offscreen to a bitmap — TODO +Extend the agent to host `NSApplication` (`.accessory`) and add a main-thread +calling surface (a `RunOnMainThread`-style verb) to invoke a real +`createPreviewView`-style entry, build an `NSHostingView`, and render it +offscreen to a bitmap. Retires U-A and U-B. +- **Verify (planned):** a test compiles a SwiftUI preview whose body renders a + known solid color to `.o`, links it in the agent, renders on the agent's main + thread, and gets back a non-empty PNG whose pixels are the expected color. + +### P3.2 — Hot update via agent respawn — TODO +Edit the preview body, recompile to a new `.o`, respawn the agent, the new +render reflects the change. No in-place patching. +- **Verify (planned):** render v1 = colorA; apply edit; respawn; render v2 = + colorB. (PID differs across the respawn by design.) + +### 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) — TODO +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. +- **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. + +## Phase 3 status: IN PROGRESS + +P3.1a done (real SwiftUI `View` body evaluates in the agent). Next: P3.1b +(offscreen render to a bitmap). + +## 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 + +P3.1b. Stand up the agent's `NSApplication` + main-thread invoke surface and +render an `NSHostingView` offscreen to a bitmap, gated by a test that asserts +the rendered pixels match a known color. From 51784cce83bc924395ab26af5fe16dce4f5bca3e Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Tue, 2 Jun 2026 12:40:38 -0400 Subject: [PATCH 03/46] JIT executor Phase 3 (P3.1b-i): build an NSHostingView on the agent's main thread (#189) Phase 2 only ran nullary functions via runAsMain on an EPC worker thread. To run real AppKit/SwiftUI the agent needs main-thread execution. Restructure the agent: the EPC server runs on a background thread and the main thread runs a CoreFoundation run loop (CFRunLoopRunInMode after NSApplicationLoad). New executor wrapper __previewsmcp_run_on_main dispatch_syncs 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 the bootstrap symbol and calls it via callSPSWrapper; Swift runOnMain(symbol:). Fixture hosting_probe.swift builds an NSHostingView on the main thread and returns 1 when fittingSize.width > 0. Retires U-A (Cocoa hosts in a headless agent) and U-B (Swift View entry on the main thread) for the construction case. Also fix a pre-existing race the restructure surfaced: native-target init ran under two separate call_once flags (in-process makeJIT and remote_session_create), so a concurrent first-time in-process + remote session create corrupted LLVM's global TargetRegistry (a hang via lookupTarget spin, or a SmallVector assert). Unify both behind one initNativeTargetOnce. 27 tests green; 12/12 parallel runs clean; zero orphan agents. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/PreviewAgent/main.cpp | 110 +++++++++++++----- Sources/PreviewsJITLink/PreviewsJITLink.swift | 8 ++ .../PreviewsJITLinkCxx/PreviewsJITLinkCxx.cpp | 48 ++++++-- .../include/PreviewsJITLinkCxx.h | 4 + .../Fixtures/hosting_probe.swift | 14 +++ .../PreviewsJITLinkTests.swift | 8 ++ docs/jit-executor-phase3-plan.md | 51 ++++++-- 7 files changed, 194 insertions(+), 49 deletions(-) create mode 100644 Tests/PreviewsJITLinkTests/Fixtures/hosting_probe.swift diff --git a/Sources/PreviewAgent/main.cpp b/Sources/PreviewAgent/main.cpp index 673eb7b..4032b8b 100644 --- a/Sources/PreviewAgent/main.cpp +++ b/Sources/PreviewAgent/main.cpp @@ -10,9 +10,12 @@ #include "llvm/Support/Error.h" #include "llvm/Support/raw_ostream.h" +#include #include +#include #include #include +#include using namespace llvm; using namespace llvm::orc; @@ -62,6 +65,28 @@ 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(); +} + CWrapperFunctionResult previewsmcp_write_pointers(const char *ArgData, size_t ArgSize) { using namespace llvm::orc::tpctypes; @@ -83,13 +108,12 @@ 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); @@ -109,31 +133,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()); + std::atomic Done{false}; + std::thread ServerThread([InFD, OutFD, &Done] { + 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.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()); + Done.store(true); + if (auto *GetMain = reinterpret_cast( + dlsym(RTLD_DEFAULT, "CFRunLoopGetMain"))) + if (auto *Stop = reinterpret_cast( + dlsym(RTLD_DEFAULT, "CFRunLoopStop"))) + Stop(GetMain()); + }); + + 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 (!Done.load()) + RunInMode(*DefaultMode, 0.25, false); + + ServerThread.join(); return 0; } diff --git a/Sources/PreviewsJITLink/PreviewsJITLink.swift b/Sources/PreviewsJITLink/PreviewsJITLink.swift index 8377a99..97b6b30 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()) diff --git a/Sources/PreviewsJITLinkCxx/PreviewsJITLinkCxx.cpp b/Sources/PreviewsJITLinkCxx/PreviewsJITLinkCxx.cpp index e77165f..73a745d 100644 --- a/Sources/PreviewsJITLinkCxx/PreviewsJITLinkCxx.cpp +++ b/Sources/PreviewsJITLinkCxx/PreviewsJITLinkCxx.cpp @@ -16,8 +16,10 @@ #include #include #include +#include #include #include +#include #include #include #include @@ -50,12 +52,19 @@ 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(); + }); +} + +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 +124,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; }; @@ -170,11 +180,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 +232,11 @@ 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)); @@ -259,6 +266,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 +298,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) { diff --git a/Sources/PreviewsJITLinkCxx/include/PreviewsJITLinkCxx.h b/Sources/PreviewsJITLinkCxx/include/PreviewsJITLinkCxx.h index 7b067b3..76dbce9 100644 --- a/Sources/PreviewsJITLinkCxx/include/PreviewsJITLinkCxx.h +++ b/Sources/PreviewsJITLinkCxx/include/PreviewsJITLinkCxx.h @@ -29,6 +29,10 @@ 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); 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/PreviewsJITLinkTests.swift b/Tests/PreviewsJITLinkTests/PreviewsJITLinkTests.swift index 2f4669c..306155f 100644 --- a/Tests/PreviewsJITLinkTests/PreviewsJITLinkTests.swift +++ b/Tests/PreviewsJITLinkTests/PreviewsJITLinkTests.swift @@ -99,6 +99,14 @@ struct PreviewsJITLinkTests { #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 publishesNewAddressIntoSlotRemotely() throws { let object = try FixtureSupport.compile("patch_slot.c") let session = try JITSession(remoteAgentPath: JITSession.bundledAgentPath()) diff --git a/docs/jit-executor-phase3-plan.md b/docs/jit-executor-phase3-plan.md index 8262b5b..7d5cd40 100644 --- a/docs/jit-executor-phase3-plan.md +++ b/docs/jit-executor-phase3-plan.md @@ -97,14 +97,41 @@ 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 — render the view offscreen to a bitmap — TODO -Extend the agent to host `NSApplication` (`.accessory`) and add a main-thread -calling surface (a `RunOnMainThread`-style verb) to invoke a real -`createPreviewView`-style entry, build an `NSHostingView`, and render it -offscreen to a bitmap. Retires U-A and U-B. +#### 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 offscreen to a bitmap — TODO +Render the `NSHostingView` to pixels on the agent's main thread and return the +bitmap to the host. Method (NSHostingView-in-window `cacheDisplay` vs SwiftUI +`ImageRenderer`) decided here. Retires the rendering half of U-A. - **Verify (planned):** a test compiles a SwiftUI preview whose body renders a known solid color to `.o`, links it in the agent, renders on the agent's main - thread, and gets back a non-empty PNG whose pixels are the expected color. + thread, and gets back a non-empty bitmap whose pixels are the expected color. ### P3.2 — Hot update via agent respawn — TODO Edit the preview body, recompile to a new `.o`, respawn the agent, the new @@ -134,8 +161,10 @@ local-only; the non-JIT build must stay green. ## Phase 3 status: IN PROGRESS -P3.1a done (real SwiftUI `View` body evaluates in the agent). Next: P3.1b -(offscreen render to a bitmap). +P3.1a done (real SwiftUI `View` body evaluates in the agent). P3.1b-i done +(`NSHostingView` builds on the agent's main thread via a `run_on_main` surface; +a pre-existing target-init race fixed). Next: P3.1b-ii (offscreen render to a +bitmap). ## Scope boundaries @@ -147,6 +176,6 @@ P3.1a done (real SwiftUI `View` body evaluates in the agent). Next: P3.1b ## Immediate next step -P3.1b. Stand up the agent's `NSApplication` + main-thread invoke surface and -render an `NSHostingView` offscreen to a bitmap, gated by a test that asserts -the rendered pixels match a known color. +P3.1b-ii. Render the `NSHostingView` (built on the agent's main thread via the +`run_on_main` surface) offscreen to a bitmap and return it to the host, gated by +a test that asserts the rendered pixels match a known color. From b5d20ae038d6dbbc3c6510872de99e6eae0c2585 Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Tue, 2 Jun 2026 14:36:42 -0400 Subject: [PATCH 04/46] JIT executor Phase 3 (P3.1b-ii): render a JIT'd SwiftUI view to a bitmap in the agent (#189) The agent now rasterizes a real JIT-linked SwiftUI view. render_probe.swift renders a known-color view via ImageRenderer on the agent's main thread (through the run_on_main surface), samples the center pixel, and returns it; the test asserts the pixel is red. Root cause of the SwiftUI-in-agent instability (Phase 2's deferred unknown U-A): the remote path used the default per-allocation mmap memory manager, which scatters each section into its own page-rounded mmap. The Swift/SwiftUI runtime's metadata walks (conformances, AttributeGraph layout descriptors) read one entry past a section into the unmapped gap and segfaulted. The six POC scenarios were small enough to dodge it; SwiftUI trips it. Fix: give the remote agent a contiguous slab via SharedMemoryMapper + MapperJITLinkMemoryManager (mirroring llvm-jitlink's createSharedMemoryManager); the agent already hosted ExecutorSharedMemoryMapperService. Suite is now 40/40 green in parallel. Two supporting fixes surfaced under the asserts build + parallel runner: - Unify native-target init behind one initNativeTargetOnce (two separate call_once flags raced on LLVM's global TargetRegistry: a hang or SmallVector assert), and serialize the shared in-process LLJIT::initialize with a mutex. - Agent _Exit(0) on disconnect instead of unwinding the Server on the background thread (a use-after-free vs the transport listen thread in handleDisconnect); the host SIGKILLs the agent regardless. Known residual (Phase 4 hardening): after the slab fix the remaining agent crashes are all post-result, during JIT'd SwiftUI object teardown (NSHostingView deinit, AttributeGraph). They fail no test. Not masked on purpose. 40/40 parallel runs green, zero orphan agents. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/PreviewAgent/main.cpp | 17 +---- .../PreviewsJITLinkCxx/PreviewsJITLinkCxx.cpp | 38 +++++++++- .../Fixtures/render_probe.swift | 24 ++++++ .../PreviewsJITLinkTests.swift | 12 +++ docs/jit-executor-phase3-plan.md | 74 +++++++++++++++---- 5 files changed, 135 insertions(+), 30 deletions(-) create mode 100644 Tests/PreviewsJITLinkTests/Fixtures/render_probe.swift diff --git a/Sources/PreviewAgent/main.cpp b/Sources/PreviewAgent/main.cpp index 4032b8b..1c20b7f 100644 --- a/Sources/PreviewAgent/main.cpp +++ b/Sources/PreviewAgent/main.cpp @@ -10,7 +10,6 @@ #include "llvm/Support/Error.h" #include "llvm/Support/raw_ostream.h" -#include #include #include #include @@ -133,8 +132,7 @@ int main(int argc, char *argv[]) { if (OutFDStr.getAsInteger(10, OutFD)) printErrorAndExit(OutFDStr + " is not a valid file descriptor"); - std::atomic Done{false}; - std::thread ServerThread([InFD, OutFD, &Done] { + std::thread ServerThread([InFD, OutFD] { ExitOnError ExitOnErr; ExitOnErr.setBanner("PreviewAgent: "); auto Server = ExitOnErr(SimpleRemoteEPCServer::Create< @@ -164,13 +162,9 @@ int main(int argc, char *argv[]) { }, InFD, OutFD)); ExitOnErr(Server->waitForDisconnect()); - Done.store(true); - if (auto *GetMain = reinterpret_cast( - dlsym(RTLD_DEFAULT, "CFRunLoopGetMain"))) - if (auto *Stop = reinterpret_cast( - dlsym(RTLD_DEFAULT, "CFRunLoopStop"))) - Stop(GetMain()); + std::_Exit(0); }); + ServerThread.detach(); if (auto *Load = reinterpret_cast( dlsym(RTLD_DEFAULT, "NSApplicationLoad"))) @@ -183,9 +177,6 @@ int main(int argc, char *argv[]) { dlsym(RTLD_DEFAULT, "kCFRunLoopDefaultMode")); if (!RunInMode || !DefaultMode) printErrorAndExit("CoreFoundation run loop symbols not found"); - while (!Done.load()) + while (true) RunInMode(*DefaultMode, 0.25, false); - - ServerThread.join(); - return 0; } diff --git a/Sources/PreviewsJITLinkCxx/PreviewsJITLinkCxx.cpp b/Sources/PreviewsJITLinkCxx/PreviewsJITLinkCxx.cpp index 73a745d..eda4410 100644 --- a/Sources/PreviewsJITLinkCxx/PreviewsJITLinkCxx.cpp +++ b/Sources/PreviewsJITLinkCxx/PreviewsJITLinkCxx.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -133,6 +134,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); } @@ -242,16 +245,45 @@ previewsmcp_jit_remote_session_create(previewsmcp_jit_session **out_session, return toCStr(std::move(err)); } + llvm::orc::SharedMemoryMapper::SymbolAddrs mapperSymbols; + if (auto err = (*epc)->getBootstrapSymbols( + {{mapperSymbols.Instance, + llvm::orc::rt::ExecutorSharedMemoryMapperServiceInstanceName}, + {mapperSymbols.Reserve, + llvm::orc::rt::ExecutorSharedMemoryMapperServiceReserveWrapperName}, + {mapperSymbols.Initialize, + llvm::orc::rt:: + ExecutorSharedMemoryMapperServiceInitializeWrapperName}, + {mapperSymbols.Deinitialize, + llvm::orc::rt:: + ExecutorSharedMemoryMapperServiceDeinitializeWrapperName}, + {mapperSymbols.Release, + llvm::orc::rt:: + ExecutorSharedMemoryMapperServiceReleaseWrapperName}})) { + llvm::consumeError((*epc)->disconnect()); + killAgent(pid); + return toCStr(std::move(err)); + } + auto jit = llvm::orc::LLJITBuilder() .setExecutorProcessControl(std::move(*epc)) .setPlatformSetUp(llvm::orc::ExecutorNativePlatform(orc_rt_path)) .setObjectLinkingLayerCreator( - [registerConformances, registerTypes]( + [registerConformances, registerTypes, mapperSymbols]( llvm::orc::ExecutionSession &es, const llvm::Triple &) -> llvm::Expected> { - auto layer = - std::make_unique(es); + auto &srepc = static_cast( + es.getExecutorProcessControl()); + auto memMgr = + llvm::orc::MapperJITLinkMemoryManager::CreateWithMapper< + llvm::orc::SharedMemoryMapper>(kSlabSize, srepc, + mapperSymbols); + if (!memMgr) { + return memMgr.takeError(); + } + auto layer = std::make_unique( + es, std::move(*memMgr)); layer->addPlugin( std::make_shared( registerConformances, registerTypes)); 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/PreviewsJITLinkTests.swift b/Tests/PreviewsJITLinkTests/PreviewsJITLinkTests.swift index 306155f..685c5df 100644 --- a/Tests/PreviewsJITLinkTests/PreviewsJITLinkTests.swift +++ b/Tests/PreviewsJITLinkTests/PreviewsJITLinkTests.swift @@ -107,6 +107,18 @@ struct PreviewsJITLinkTests { #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/docs/jit-executor-phase3-plan.md b/docs/jit-executor-phase3-plan.md index 7d5cd40..bf2f2b0 100644 --- a/docs/jit-executor-phase3-plan.md +++ b/docs/jit-executor-phase3-plan.md @@ -125,13 +125,59 @@ 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 offscreen to a bitmap — TODO -Render the `NSHostingView` to pixels on the agent's main thread and return the -bitmap to the host. Method (NSHostingView-in-window `cacheDisplay` vs SwiftUI -`ImageRenderer`) decided here. Retires the rendering half of U-A. -- **Verify (planned):** a test compiles a SwiftUI preview whose body renders a - known solid color to `.o`, links it in the agent, renders on the agent's main - thread, and gets back a non-empty bitmap whose pixels are the expected color. +#### 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:** give the remote agent a **contiguous slab** via the shared-memory +mapper (`SharedMemoryMapper` + `MapperJITLinkMemoryManager`, mirroring +`llvm-jitlink`'s `createSharedMemoryManager`); the agent already hosted +`ExecutorSharedMemoryMapperService`. With the slab the section walks stay +in-bounds and the suite is **40/40 green** across parallel runs. + +**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. + +**Known residual → Phase 4 (crash recovery / hardening).** After the slab fix the +remaining agent crashes are all **post-result**: the agent returns the correct +value, the test passes, then the JIT-linked SwiftUI objects deallocate +(`NSHostingView` deinit, AttributeGraph `Node::destroy`, background conformance +cleanup) and crash during teardown, before the host's `SIGKILL`. They fail no test +(hence 40/40) but write crash reports. We deliberately do **not** mask them (e.g. +by leaking the view): tearing down a JIT'd SwiftUI view is genuinely broken and +P3.2/P3.4 will dealloc these views for real on every edit, so the signal is worth +keeping. Hardening JIT'd-SwiftUI teardown is Phase 4. ### P3.2 — Hot update via agent respawn — TODO Edit the preview body, recompile to a new `.o`, respawn the agent, the new @@ -161,10 +207,10 @@ local-only; the non-JIT build must stay green. ## Phase 3 status: IN PROGRESS -P3.1a done (real SwiftUI `View` body evaluates in the agent). P3.1b-i done -(`NSHostingView` builds on the agent's main thread via a `run_on_main` surface; -a pre-existing target-init race fixed). Next: P3.1b-ii (offscreen render to a -bitmap). +P3.1 done. The agent links a real SwiftUI preview, runs its body and renders it +to a bitmap on the agent's main thread (P3.1a/b-i/b-ii), with the remote slab +mapper (U-A) the load-bearing fix. Suite 40/40 green in parallel. Next: P3.2 +(hot update via agent respawn). ## Scope boundaries @@ -176,6 +222,6 @@ bitmap). ## Immediate next step -P3.1b-ii. Render the `NSHostingView` (built on the agent's main thread via the -`run_on_main` surface) offscreen to a bitmap and return it to the host, gated by -a test that asserts the rendered pixels match a known color. +P3.2. Hot update via agent respawn: edit the preview body, recompile to a new +`.o`, respawn the agent, confirm the new render reflects the change (render v1 = +colorA, edit, respawn, render v2 = colorB). From a8d590916b52ac34d0818fb0cf9a50f513d841be Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Tue, 2 Jun 2026 18:55:16 -0400 Subject: [PATCH 05/46] JIT executor Phase 3: anonymous contiguous slab mapper, replacing the shared-memory slab (#189) The shared-memory slab (added for U-A contiguity) requires mprotect-to-exec on MAP_SHARED memory, which macOS denies (EACCES), so every remote session failed with "Failed to materialize symbols { ___mh_executable_header, ___dso_handle }: Permission denied". macOS allows mmap-exec-from-start but not the mprotect transition; the JIT entitlement does not help. Apple's XCPreviewAgent sidesteps this entirely: it carries no JIT entitlement and drives anonymous executable memory over the wire (XOJITExecutor's write_mem), never shared memory. Match Apple: replace the shared-memory slab with a contiguous slab backed by anonymous executor memory. - Agent: a small mapper service (reserve = anonymous mmap; initialize = memcpy the wire-transferred content + mprotect + invalidate icache + run finalize actions; deinitialize = no-op; release = munmap), advertised as bootstrap symbols. - 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. This fixes U-A (the SwiftUI metadata "read past region" crashes) via contiguity, using anonymous memory so the macOS shared-exec restriction never applies. Two details were load-bearing: the agent must InvalidateInstructionCache after writing exec segments (ARM64 executes stale icache otherwise, an intermittent garbage-execution crash), and it discards deallocation actions (the JIT image is process-lived in the respawn model per Phase 1 D3, so running JIT'd destructors at teardown is both unnecessary and unsafe). Suite 15/15 green across parallel runs, zero agent crashes, zero orphans. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/PreviewAgent/main.cpp | 97 ++++++++++ .../PreviewsJITLinkCxx/PreviewsJITLinkCxx.cpp | 174 +++++++++++++++--- 2 files changed, 247 insertions(+), 24 deletions(-) diff --git a/Sources/PreviewAgent/main.cpp b/Sources/PreviewAgent/main.cpp index 1c20b7f..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,13 +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; @@ -86,6 +93,88 @@ CWrapperFunctionResult previewsmcp_run_on_main(const char *ArgData, .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; @@ -151,6 +240,14 @@ int main(int argc, char *argv[]) { 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( diff --git a/Sources/PreviewsJITLinkCxx/PreviewsJITLinkCxx.cpp b/Sources/PreviewsJITLinkCxx/PreviewsJITLinkCxx.cpp index eda4410..0d37467 100644 --- a/Sources/PreviewsJITLinkCxx/PreviewsJITLinkCxx.cpp +++ b/Sources/PreviewsJITLinkCxx/PreviewsJITLinkCxx.cpp @@ -17,7 +17,6 @@ #include #include #include -#include #include #include #include @@ -25,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -61,6 +61,144 @@ void initNativeTargetOnce() { }); } +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(); @@ -245,21 +383,12 @@ previewsmcp_jit_remote_session_create(previewsmcp_jit_session **out_session, return toCStr(std::move(err)); } - llvm::orc::SharedMemoryMapper::SymbolAddrs mapperSymbols; + AnonMapperSymbols anonSyms; if (auto err = (*epc)->getBootstrapSymbols( - {{mapperSymbols.Instance, - llvm::orc::rt::ExecutorSharedMemoryMapperServiceInstanceName}, - {mapperSymbols.Reserve, - llvm::orc::rt::ExecutorSharedMemoryMapperServiceReserveWrapperName}, - {mapperSymbols.Initialize, - llvm::orc::rt:: - ExecutorSharedMemoryMapperServiceInitializeWrapperName}, - {mapperSymbols.Deinitialize, - llvm::orc::rt:: - ExecutorSharedMemoryMapperServiceDeinitializeWrapperName}, - {mapperSymbols.Release, - llvm::orc::rt:: - ExecutorSharedMemoryMapperServiceReleaseWrapperName}})) { + {{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)); @@ -270,20 +399,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, mapperSymbols]( - llvm::orc::ExecutionSession &es, const llvm::Triple &) + [registerConformances, registerTypes, + anonSyms](llvm::orc::ExecutionSession &es, const llvm::Triple &) -> llvm::Expected> { auto &srepc = static_cast( es.getExecutorProcessControl()); auto memMgr = - llvm::orc::MapperJITLinkMemoryManager::CreateWithMapper< - llvm::orc::SharedMemoryMapper>(kSlabSize, srepc, - mapperSymbols); - if (!memMgr) { - return memMgr.takeError(); - } + std::make_unique( + kSlabSize, std::make_unique( + srepc, anonSyms)); auto layer = std::make_unique( - es, std::move(*memMgr)); + es, std::move(memMgr)); layer->addPlugin( std::make_shared( registerConformances, registerTypes)); From e55f89bd53469d9d42b16a943e3fb9fe6acd9e82 Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Tue, 2 Jun 2026 18:56:47 -0400 Subject: [PATCH 06/46] Update Phase 3 plan: shared-memory slab reverted as macOS-incompatible; anonymous mapper (P3.1b-iii) (#189) Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/jit-executor-phase3-plan.md | 73 ++++++++++++++++++++++++-------- 1 file changed, 56 insertions(+), 17 deletions(-) diff --git a/docs/jit-executor-phase3-plan.md b/docs/jit-executor-phase3-plan.md index bf2f2b0..abf7c37 100644 --- a/docs/jit-executor-phase3-plan.md +++ b/docs/jit-executor-phase3-plan.md @@ -145,11 +145,51 @@ 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:** give the remote agent a **contiguous slab** via the shared-memory -mapper (`SharedMemoryMapper` + `MapperJITLinkMemoryManager`, mirroring -`llvm-jitlink`'s `createSharedMemoryManager`); the agent already hosted -`ExecutorSharedMemoryMapperService`. With the slab the section walks stay -in-bounds and the suite is **40/40 green** across parallel runs. +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):** @@ -169,15 +209,12 @@ runner):** **Verify (met):** `rendersViewToBitmapOnMainThreadRemotely` returns a red pixel; suite 40/40 green across parallel runs, zero orphan agents. -**Known residual → Phase 4 (crash recovery / hardening).** After the slab fix the -remaining agent crashes are all **post-result**: the agent returns the correct -value, the test passes, then the JIT-linked SwiftUI objects deallocate -(`NSHostingView` deinit, AttributeGraph `Node::destroy`, background conformance -cleanup) and crash during teardown, before the host's `SIGKILL`. They fail no test -(hence 40/40) but write crash reports. We deliberately do **not** mask them (e.g. -by leaking the view): tearing down a JIT'd SwiftUI view is genuinely broken and -P3.2/P3.4 will dealloc these views for real on every edit, so the signal is worth -keeping. Hardening JIT'd-SwiftUI teardown is Phase 4. +**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 — TODO Edit the preview body, recompile to a new `.o`, respawn the agent, the new @@ -208,9 +245,11 @@ local-only; the non-JIT build must stay green. ## Phase 3 status: IN PROGRESS P3.1 done. The agent links a real SwiftUI preview, runs its body and renders it -to a bitmap on the agent's main thread (P3.1a/b-i/b-ii), with the remote slab -mapper (U-A) the load-bearing fix. Suite 40/40 green in parallel. Next: P3.2 -(hot update via agent respawn). +to a bitmap on the agent's main thread (P3.1a/b-i/b-ii), over a contiguous +**anonymous** executor-memory slab (P3.1b-iii) that fixes U-A while staying +within macOS's executable-memory rules (the shared-memory slab was reverted as +macOS-incompatible). Suite 28/28, 15/15 parallel runs green, zero crashes. Next: +P3.2 (hot update via agent respawn). ## Scope boundaries From d8b7a81dae9e66c035daf05d3e2bdd6072363f62 Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Tue, 2 Jun 2026 19:28:56 -0400 Subject: [PATCH 07/46] Phase 3 plan: P3.2 resume pointer + pitfalls; flag the confounded Compiler-target finding (#189) Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/jit-executor-phase3-plan.md | 61 +++++++++++++++++++++++++++----- 1 file changed, 52 insertions(+), 9 deletions(-) diff --git a/docs/jit-executor-phase3-plan.md b/docs/jit-executor-phase3-plan.md index abf7c37..f43d87f 100644 --- a/docs/jit-executor-phase3-plan.md +++ b/docs/jit-executor-phase3-plan.md @@ -216,11 +216,32 @@ 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 — TODO +### P3.2 — Hot update via agent respawn — TODO (resume here) Edit the preview body, recompile to a new `.o`, respawn the agent, the new -render reflects the change. No in-place patching. -- **Verify (planned):** render v1 = colorA; apply edit; respawn; render v2 = - colorB. (PID differs across the respawn by design.) +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. +- **Planned test** (in `CompilerObjectTests`, mirrors `reResolvesSymbolAfterRecompile`, + needs no new production code): compile a render-probe-style source set to + color A 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**, link in a **fresh** remote session, render, assert + blue. Proves recompile → respawn → re-render with the real `Compiler`. +- **CAVEAT — re-verify cleanly, prior finding is confounded.** A first P3.2 + attempt concluded `Compiler.compileObject`'s default `-target + arm64-apple-macosx14.0` produces objects that fail in the JIT (minos 14 → + Swift back-deployment referencing `___mh_executable_header`/`___dso_handle`) + and that the JIT path needs the **host-OS** target. **That whole diagnosis ran + during the macOS shared-exec outage, so every compile variant failed for the + unrelated shm reason — the target/minos/arch conclusions are unreliable.** + Start P3.2 by re-checking, on the now-working anonymous mapper, whether the + default `Compiler` target links in the agent at all. Only add a host-OS + `target:` option to `Compiler.compileObject` if it genuinely fails (it may + not). The fixtures compile with `xcrun swiftc` and no `-target` (host default), + which is the known-good recipe. +- **Verify (planned):** render v1 = red; recompile edited source; render v2 = + blue, in a fresh agent. (PID differs across the respawn by design.) ### P3.3 — Begin/End/cancelUpdate handshake (§5/§6) — CONDITIONAL Only if no-restart with `@State` preservation later earns its keep. Bracket an @@ -259,8 +280,30 @@ P3.2 (hot update via agent respawn). not pulled in); large-module scaling; XPC/gRPC transports; iOS device agent; LLVM bundling; crash recovery; multi-session. -## Immediate next step - -P3.2. Hot update via agent respawn: edit the preview body, recompile to a new -`.o`, respawn the agent, confirm the new render reflects the change (render v1 = -colorA, edit, respawn, render v2 = colorB). +## Immediate next step (resume pointer for a fresh session) + +**State:** P3.1 is DONE and on `main`-bound PR #190, CI green. Branch +`jit-phase3-session-integration`, baseline commit **`a8d5909`** (anonymous +contiguous mapper). Working tree clean. Run JIT tests with +`swift test --filter PreviewsJITLinkTests` (builds `PreviewAgent` too); expect +28 tests green, zero orphan `PreviewAgent` processes. If `third_party/llvm-build` +is missing, run the bootstrap skill with `--jit` (do NOT rebuild LLVM if it +exists). + +**Next:** P3.2 (hot update via agent respawn) — see the P3.2 subproblem above +for the test design and the **confounded-finding caveat** (re-verify whether +`Compiler.compileObject`'s default target works in the JIT before assuming it +needs a host-OS target). + +**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. From 302abe450e8e4d74a097f56fac00cb52ca480b0f Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Tue, 2 Jun 2026 19:37:07 -0400 Subject: [PATCH 08/46] JIT executor Phase 3 (P3.2): hot update via agent respawn (#189) Recompile a render-probe source to a new .o, respawn the agent (fresh remote JITSession), and confirm the new render reflects the edit. v1 renders red; the same source edited to blue renders blue in a fresh agent. Proves recompile -> respawn -> re-render through the real Compiler, respawn-first (no in-place write_mem). The earlier "Compiler.compileObject's default arm64-apple-macosx14.0 target fails in the JIT" finding was confounded by the macOS shared-exec outage. Re-verified cleanly on the anonymous mapper: the default target links and renders in the agent, so compileObject is unchanged (no host-OS target option). Test only. Co-Authored-By: Claude Opus 4.8 --- .../CompilerObjectTests.swift | 59 ++++++++++++++++ docs/jit-executor-phase3-plan.md | 70 +++++++++---------- 2 files changed, 94 insertions(+), 35 deletions(-) diff --git a/Tests/PreviewsJITLinkTests/CompilerObjectTests.swift b/Tests/PreviewsJITLinkTests/CompilerObjectTests.swift index 3da5e12..281b83c 100644 --- a/Tests/PreviewsJITLinkTests/CompilerObjectTests.swift +++ b/Tests/PreviewsJITLinkTests/CompilerObjectTests.swift @@ -51,4 +51,63 @@ 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) + } } diff --git a/docs/jit-executor-phase3-plan.md b/docs/jit-executor-phase3-plan.md index f43d87f..64dfaa0 100644 --- a/docs/jit-executor-phase3-plan.md +++ b/docs/jit-executor-phase3-plan.md @@ -216,32 +216,30 @@ 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 — TODO (resume here) +### 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. -- **Planned test** (in `CompilerObjectTests`, mirrors `reResolvesSymbolAfterRecompile`, - needs no new production code): compile a render-probe-style source set to - color A 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**, link in a **fresh** remote session, render, assert - blue. Proves recompile → respawn → re-render with the real `Compiler`. -- **CAVEAT — re-verify cleanly, prior finding is confounded.** A first P3.2 - attempt concluded `Compiler.compileObject`'s default `-target - arm64-apple-macosx14.0` produces objects that fail in the JIT (minos 14 → - Swift back-deployment referencing `___mh_executable_header`/`___dso_handle`) - and that the JIT path needs the **host-OS** target. **That whole diagnosis ran - during the macOS shared-exec outage, so every compile variant failed for the - unrelated shm reason — the target/minos/arch conclusions are unreliable.** - Start P3.2 by re-checking, on the now-working anonymous mapper, whether the - default `Compiler` target links in the agent at all. Only add a host-OS - `target:` option to `Compiler.compileObject` if it genuinely fails (it may - not). The fixtures compile with `xcrun swiftc` and no `-target` (host default), - which is the known-good recipe. -- **Verify (planned):** render v1 = red; recompile edited source; render v2 = - blue, in a fresh agent. (PID differs across the respawn by design.) +- **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 @@ -265,12 +263,14 @@ local-only; the non-JIT build must stay green. ## Phase 3 status: IN PROGRESS -P3.1 done. The agent links a real SwiftUI preview, runs its body and renders it -to a bitmap on the agent's main thread (P3.1a/b-i/b-ii), over a contiguous -**anonymous** executor-memory slab (P3.1b-iii) that fixes U-A while staying -within macOS's executable-memory rules (the shared-memory slab was reverted as -macOS-incompatible). Suite 28/28, 15/15 parallel runs green, zero crashes. Next: -P3.2 (hot update via agent respawn). +P3.1 + P3.2 done. The agent links a real SwiftUI preview, runs its body and +renders it to a bitmap on the agent's main thread (P3.1a/b-i/b-ii), over a +contiguous **anonymous** executor-memory slab (P3.1b-iii) that fixes U-A while +staying within macOS's executable-memory rules (the shared-memory slab was +reverted as macOS-incompatible). P3.2 proves recompile → respawn → re-render +through the real `Compiler` (default target works, no `compileObject` change). +Suite 29/29, 10/10 parallel runs green, zero crashes. Next: P3.4 (daemon +session-lifecycle integration; P3.3 stays conditional). ## Scope boundaries @@ -282,18 +282,18 @@ P3.2 (hot update via agent respawn). ## Immediate next step (resume pointer for a fresh session) -**State:** P3.1 is DONE and on `main`-bound PR #190, CI green. Branch -`jit-phase3-session-integration`, baseline commit **`a8d5909`** (anonymous -contiguous mapper). Working tree clean. Run JIT tests with +**State:** P3.1 + P3.2 are DONE on `main`-bound PR #190, CI green. Branch +`jit-phase3-session-integration` (baseline `a8d5909` = anonymous contiguous +mapper; P3.2 adds only a test). Working tree clean. Run JIT tests with `swift test --filter PreviewsJITLinkTests` (builds `PreviewAgent` too); expect -28 tests green, zero orphan `PreviewAgent` processes. If `third_party/llvm-build` +29 tests green, zero orphan `PreviewAgent` processes. If `third_party/llvm-build` is missing, run the bootstrap skill with `--jit` (do NOT rebuild LLVM if it exists). -**Next:** P3.2 (hot update via agent respawn) — see the P3.2 subproblem above -for the test design and the **confounded-finding caveat** (re-verify whether -`Compiler.compileObject`'s default target works in the JIT before assuming it -needs a host-OS target). +**Next:** P3.4 (daemon session-lifecycle integration, pulls in SP5) — route +structural edits from `PreviewSession`/FileWatcher to the JIT respawn path while +literal-only edits stay on `DesignTimeStore`. P3.3 (Begin/End/cancelUpdate +handshake) stays conditional. The respawn mechanism itself is proven by P3.2. **Pitfalls carried forward:** - macOS denies `mprotect`-to-exec on `MAP_SHARED` memory (no entitlement / reboot From 8ee5b1d3e8d044f35d355d7a0b9507ce6e497f63 Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Tue, 2 Jun 2026 19:49:48 -0400 Subject: [PATCH 09/46] docs/jit-executor-phase3-plan: record recompile-narrowing gaps (#189) P3.4 routes structural edits to JIT respawn but still recompiles the whole module; respawn removes the relink, not the compile. Add G1 (single-file incremental compile vs prebuilt .swiftmodule), G2 (file-identifying FileWatcher), the stable-module vs any-file model mismatch, and the caveat that Apple's compile-side narrowing is inferred (W3) not captured. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/jit-executor-phase3-plan.md | 46 ++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/docs/jit-executor-phase3-plan.md b/docs/jit-executor-phase3-plan.md index 64dfaa0..332f638 100644 --- a/docs/jit-executor-phase3-plan.md +++ b/docs/jit-executor-phase3-plan.md @@ -261,6 +261,52 @@ 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 — Single-file incremental compile against a prebuilt module +**Missing.** A way to recompile only the edited file. A Swift module compiles as +a unit, so a lone file cannot see its siblings' declarations. Narrowing requires +building the target's stable module once to a reusable `.swiftmodule` (design §3 +"stable module", already emitted to `.o`), then compiling the changed file +against that interface and handing the single `.o` to the JIT to override the old +symbols. `Compiler.compileObject` takes one source today but no prebuilt-module +input; the `.swiftmodule` emission + `-I`/merge wiring does not exist. +- **Verify:** on a ≥500-file example, edit one file; swiftc runs on exactly one + source against the prebuilt `.swiftmodule`; compile wall-clock is sub-second + and roughly independent of module size. + +### 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. + +### Model mismatch to resolve +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 slow full-rebuild path. "Any edited file reloads sub-second" is +a *stronger* guarantee than the design currently makes and needs G1+G2. Decide +whether Phase 3/4 targets only preview-layer edits or true any-file incremental. + +### Apple-evidence caveat +previews-research **verified** Apple's respawn-only *dispatch* (8 edit kinds, +zero `write_mem`; `w3-empirical-capture.md`). It did **not** capture the compile +side: "incremental swiftc" is an *inference* from latency-by-edit-kind, hedged in +the design ("appears to have only ONE path for edit dispatch"). G1's premise that +Apple recompiles a single file is unconfirmed; validating it (or just measuring +our own G1 win) is the gate. + ## Phase 3 status: IN PROGRESS P3.1 + P3.2 done. The agent links a real SwiftUI preview, runs its body and From 85bf7b87061732d2301f5dc23837307c1670612e Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Tue, 2 Jun 2026 19:58:54 -0400 Subject: [PATCH 10/46] Phase 3 plan: record P3.4 render-surface decision (agent bitmaps) + chunking (#189) Model A (agent-rendered bitmaps) chosen over in-process JIT-in-daemon: in-process linking reintroduces the per-edit Swift-metadata leak that respawn-first exists to avoid. Cost is small because macOS is snapshot-only (preview_touch is iOS-only, separate process, out of scope), so only preview_snapshot moves to the agent bitmap. Records the new unknowns U-E (variable-size bitmap return) and U-F (real bridge render) and the de-risk-first chunking P3.4a-d. Co-Authored-By: Claude Opus 4.8 --- docs/jit-executor-phase3-plan.md | 40 +++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/docs/jit-executor-phase3-plan.md b/docs/jit-executor-phase3-plan.md index 332f638..8824cb7 100644 --- a/docs/jit-executor-phase3-plan.md +++ b/docs/jit-executor-phase3-plan.md @@ -248,10 +248,48 @@ 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) — TODO +### 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).** Hand a rendered bitmap from the + agent back to the host. *Verify:* a render fixture produces a known bitmap; the + host receives matching pixels/bytes. +- **P3.4b — real bridge renders in the agent (U-F).** Build a trivial SwiftUI + `#Preview` through the production bridge, link in the agent, render via the + P3.4a surface, assert the known color. +- **P3.4c — daemon seam (U-D).** Route structural edits to + recompile → respawn → agent-render → serve `preview_snapshot`; literal path + unchanged. +- **P3.4d — latency (U-C).** Measure structural respawn vs the <200ms target. + - **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. From e19f2179a3c2995416880ec507470cb393f34c64 Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Tue, 2 Jun 2026 20:01:03 -0400 Subject: [PATCH 11/46] JIT executor Phase 3 (P3.4a): agent renders a bitmap to a host-supplied file (#189) Retires U-E (variable-size bitmap return from the agent) via file transport: the agent renders a SwiftUI view and writes the PNG to a host-supplied path; the host reads and decodes it. Reuses the runOnMain Int32 status surface and the existing bridge-source-templating pattern, so no new EPC/C++ wire code is needed. The EPC byte-return wrapper stays a fallback only if file transport proves inadequate (it won't for snapshots). Test only. Co-Authored-By: Claude Opus 4.8 --- .../CompilerObjectTests.swift | 52 +++++++++++++++++++ docs/jit-executor-phase3-plan.md | 13 +++-- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/Tests/PreviewsJITLinkTests/CompilerObjectTests.swift b/Tests/PreviewsJITLinkTests/CompilerObjectTests.swift index 281b83c..74c948f 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 @@ -110,4 +112,54 @@ struct CompilerObjectTests { 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) + } } diff --git a/docs/jit-executor-phase3-plan.md b/docs/jit-executor-phase3-plan.md index 8824cb7..5553941 100644 --- a/docs/jit-executor-phase3-plan.md +++ b/docs/jit-executor-phase3-plan.md @@ -279,9 +279,16 @@ moves to the agent bitmap; interaction is untouched. self-contained `ImageRenderer` probe. **Chunking (de-risk first):** -- **P3.4a — agent bitmap-return surface (U-E).** Hand a rendered bitmap from the - agent back to the host. *Verify:* a render fixture produces a known bitmap; the - host receives matching pixels/bytes. +- **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).** Build a trivial SwiftUI `#Preview` through the production bridge, link in the agent, render via the P3.4a surface, assert the known color. From 020f63c9117bc426021585be9e99671f4051fa43 Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Tue, 2 Jun 2026 20:13:12 -0400 Subject: [PATCH 12/46] docs/jit-executor-phase3-plan: refine G1 with W4 findings (#189) W4 (previews-research) closed the compile-side question: Apple recompiles one file. Refine G1 to split cross-module (prebuilt .swiftmodule, size-independent) from same-module (full parse + incremental codegen, size-dependent; JIT must relink the set of changed objects). Name the two impl paths (adopt swiftc incremental vs the stable-module/thunk split) and tie verify to 500/1000-file scaling. Update the Apple caveat to W4-CLOSED with its residual gaps and the design-time-injection bonus. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/jit-executor-phase3-plan.md | 57 ++++++++++++++++++++++---------- 1 file changed, 39 insertions(+), 18 deletions(-) diff --git a/docs/jit-executor-phase3-plan.md b/docs/jit-executor-phase3-plan.md index 5553941..d53d953 100644 --- a/docs/jit-executor-phase3-plan.md +++ b/docs/jit-executor-phase3-plan.md @@ -317,17 +317,35 @@ 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 — Single-file incremental compile against a prebuilt module -**Missing.** A way to recompile only the edited file. A Swift module compiles as -a unit, so a lone file cannot see its siblings' declarations. Narrowing requires -building the target's stable module once to a reusable `.swiftmodule` (design §3 -"stable module", already emitted to `.o`), then compiling the changed file -against that interface and handing the single `.o` to the JIT to override the old -symbols. `Compiler.compileObject` takes one source today but no prebuilt-module -input; the `.swiftmodule` emission + `-I`/merge wiring does not exist. -- **Verify:** on a ≥500-file example, edit one file; swiftc runs on exactly one - source against the prebuilt `.swiftmodule`; compile wall-clock is sub-second - and roughly independent of module size. +### 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 follow: (a) adopt swiftc incremental honestly (persistent +per-session build dir, full filelist, re-emit changed objects) — accepts the +same-module size scaling; (b) the stable-module/thunk split — put the editable +preview in a **separate** module that imports the bulk as a prebuilt +`.swiftmodule`, which is the only way to make reload time independent of the big +module's size (it turns same-module cost into cross-module reuse). +- **Verify:** on 500- and 1000-file modules, measure incremental same-module + re-emit vs the split. Same-module compile scales with N (parse/type-check + bound); the split stays roughly flat. The <200ms target holds for the split + (or small modules), not for same-module at scale. ### G2 — A file-identifying FileWatcher **Missing.** `FileWatcher` signals only "something in the watched set changed", @@ -344,13 +362,16 @@ file falls to the slow full-rebuild path. "Any edited file reloads sub-second" i a *stronger* guarantee than the design currently makes and needs G1+G2. Decide whether Phase 3/4 targets only preview-layer edits or true any-file incremental. -### Apple-evidence caveat -previews-research **verified** Apple's respawn-only *dispatch* (8 edit kinds, -zero `write_mem`; `w3-empirical-capture.md`). It did **not** capture the compile -side: "incremental swiftc" is an *inference* from latency-by-edit-kind, hedged in -the design ("appears to have only ONE path for edit dispatch"). G1's premise that -Apple recompiles a single file is unconfirmed; validating it (or just measuring -our own G1 win) is the gate. +### Apple-evidence status (W4 — CLOSED) +W3 verified Apple's respawn-only *dispatch* (8 edit kinds, zero `write_mem`; +`w3-empirical-capture.md`). W4 (`w4-compile-side.md`) now closes the compile +side: Apple recompiles **one file**, confirmed by object-mtime diff (1 of 61 per +edit) and the quoted swiftc line. Two residual gaps W4 leaves open: the canvas +thunk-compile **command line** is not captured (SIP blocked dtrace; only its +on-disk artifacts), and dependency fan-out for a public-API edit was reasoned, +not measured at scale. W4 also surfaced a bonus: Apple's literal fast-path is +**data injection** (`__designTimeString`/`Integer` + fallback), not +recompilation — the same idea as this project's `DesignTimeStore`. ## Phase 3 status: IN PROGRESS From d6216ecc7572ad293a5bdfe5a9d9fc04cdb8bef5 Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Tue, 2 Jun 2026 20:13:58 -0400 Subject: [PATCH 13/46] JIT executor Phase 3 (P3.4b): render seam in BridgeGenerator for the agent (#189) Retires U-F (the real Compiler bridge renders in the agent, not a probe). Adds generateCombinedSource(renderOutputPath:), which 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 agent's runOnMain surface; the path is baked because the daemon recompiles per structural edit (model A, file transport). createPreviewView is unchanged, so the in-daemon path is undisturbed. Test drives a real combined bridge (DesignTimeStore + __PreviewBridge + thunk'd user #Preview) through the agent and asserts the host-decoded PNG is green. 31/31 JIT green, 69/69 BridgeGenerator core green. Co-Authored-By: Claude Opus 4.8 --- Sources/PreviewsCore/BridgeGenerator.swift | 35 +++++++++++++- .../CompilerObjectTests.swift | 48 +++++++++++++++++++ docs/jit-executor-phase3-plan.md | 16 +++++-- 3 files changed, 95 insertions(+), 4 deletions(-) diff --git a/Sources/PreviewsCore/BridgeGenerator.swift b/Sources/PreviewsCore/BridgeGenerator.swift index 19bb8e5..b803961 100644 --- a/Sources/PreviewsCore/BridgeGenerator.swift +++ b/Sources/PreviewsCore/BridgeGenerator.swift @@ -21,7 +21,8 @@ public enum BridgeGenerator { platform: PreviewPlatform = .macOS, traits: PreviewTraits = PreviewTraits(), setupModule: String? = nil, - setupType: String? = nil + setupType: String? = nil, + renderOutputPath: String? = nil ) -> (source: String, literals: [LiteralEntry]) { // Transform source to replace literals with DesignTimeStore lookups let thunkResult = ThunkGenerator.transform(source: originalSource) @@ -53,6 +54,8 @@ public enum BridgeGenerator { """ let bodyKindEntry = bodyKindEntryPoint(closureBody: transformedClosureBody) + let renderEntry = + renderOutputPath.map { renderToFileEntryPoint(viewCode: viewCode, path: $0) } ?? "" let bridgeCode: String switch platform { case .macOS: @@ -68,6 +71,8 @@ public enum BridgeGenerator { } \(bodyKindEntry) + + \(renderEntry) """ case .iOS: bridgeCode = """ @@ -246,6 +251,34 @@ 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) -> String { + """ + @_cdecl("renderPreviewToFile") + public func renderPreviewToFile() -> Int32 { + MainActor.assumeIsolated { + 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/Tests/PreviewsJITLinkTests/CompilerObjectTests.swift b/Tests/PreviewsJITLinkTests/CompilerObjectTests.swift index 74c948f..9296baa 100644 --- a/Tests/PreviewsJITLinkTests/CompilerObjectTests.swift +++ b/Tests/PreviewsJITLinkTests/CompilerObjectTests.swift @@ -162,4 +162,52 @@ struct CompilerObjectTests { #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/docs/jit-executor-phase3-plan.md b/docs/jit-executor-phase3-plan.md index d53d953..e7ff838 100644 --- a/docs/jit-executor-phase3-plan.md +++ b/docs/jit-executor-phase3-plan.md @@ -289,9 +289,19 @@ moves to the agent bitmap; interaction is untouched. 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).** Build a trivial SwiftUI - `#Preview` through the production bridge, link in the agent, render via the - P3.4a surface, assert the known color. +- **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. From 568048d4cc8a5c6e24bf7103b6b9b0bac8cbca6a Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Tue, 2 Jun 2026 21:44:44 -0400 Subject: [PATCH 14/46] docs/jit-executor-phase3-plan: fold in W4/W5 verdict (#189) W4 live-captured Apple's preview thunk argv (one -primary-file, -vfsoverlay, explicit module map => G1 cross-module shape confirmed) and showed previews use PreviewRegistry-reentry + respawn, NOT dynamic replacement on Xcode 26.x. W5 measured same-module incremental scaling (linear, 200ms budget breaks ~25 files) vs the split (flat ~0.144s to 1000 files), making the stable-module/editable-unit split mandatory; fan-out is 1 object per body edit, 1+K per interface edit. W7 auto-split is now the critical path. Update G1 verdict + Apple-evidence status. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/jit-executor-phase3-plan.md | 60 ++++++++++++++++++++++---------- 1 file changed, 41 insertions(+), 19 deletions(-) diff --git a/docs/jit-executor-phase3-plan.md b/docs/jit-executor-phase3-plan.md index e7ff838..4ae80c6 100644 --- a/docs/jit-executor-phase3-plan.md +++ b/docs/jit-executor-phase3-plan.md @@ -346,16 +346,26 @@ module cannot import itself: `Compiler.compileObject` takes one source today and does neither: no persistent `-incremental`/output-file-map state, no prebuilt-`.swiftmodule` inputs. Two -implementation paths follow: (a) adopt swiftc incremental honestly (persistent -per-session build dir, full filelist, re-emit changed objects) — accepts the -same-module size scaling; (b) the stable-module/thunk split — put the editable -preview in a **separate** module that imports the bulk as a prebuilt -`.swiftmodule`, which is the only way to make reload time independent of the big -module's size (it turns same-module cost into cross-module reuse). -- **Verify:** on 500- and 1000-file modules, measure incremental same-module - re-emit vs the split. Same-module compile scales with N (parse/type-check - bound); the split stays roughly flat. The <200ms target holds for the split - (or small modules), not for same-module at scale. +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):** the same-module-vs-split curves above. Remaining build + question is W7 — can we auto-split an arbitrary target without the user + restructuring their package (access-level visibility, symbol override). ### G2 — A file-identifying FileWatcher **Missing.** `FileWatcher` signals only "something in the watched set changed", @@ -372,16 +382,28 @@ file falls to the slow full-rebuild path. "Any edited file reloads sub-second" i a *stronger* guarantee than the design currently makes and needs G1+G2. Decide whether Phase 3/4 targets only preview-layer edits or true any-file incremental. -### Apple-evidence status (W4 — CLOSED) +### 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`) now closes the compile -side: Apple recompiles **one file**, confirmed by object-mtime diff (1 of 61 per -edit) and the quoted swiftc line. Two residual gaps W4 leaves open: the canvas -thunk-compile **command line** is not captured (SIP blocked dtrace; only its -on-disk artifacts), and dependency fan-out for a public-API edit was reasoned, -not measured at scale. W4 also surfaced a bonus: Apple's literal fast-path is -**data injection** (`__designTimeString`/`Integer` + fallback), not -recompilation — the same idea as this project's `DesignTimeStore`. +`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`. Modeling it is W6. **Open:** W7 (auto-split +feasibility) is now the critical path. ## Phase 3 status: IN PROGRESS From bb68a8627cc7c545f1f34d22ea62ef2a298fadac Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Wed, 3 Jun 2026 22:25:18 -0400 Subject: [PATCH 15/46] docs/jit-executor-phase3-plan: fold in W7 verdict, resolve model mismatch (#189) W7 closed: auto-split is feasible for the common case (internal view in a separate editable unit via @testable against a -enable-testing stable module; edit->relink flat ~0.14s at N=200/1000). Record the four break-cases (private/fileprivate, @_spi, stable-interface edits, package decls) and the two soft spots, both covered by the assigned integrated POC. Resolve the model- mismatch decision: preview-layer edits get the flat fast path; stable-interface edits accept W5 same-module cost; any-file sub-second is explicitly a non-goal. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/jit-executor-phase3-plan.md | 35 ++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/docs/jit-executor-phase3-plan.md b/docs/jit-executor-phase3-plan.md index 4ae80c6..3626f76 100644 --- a/docs/jit-executor-phase3-plan.md +++ b/docs/jit-executor-phase3-plan.md @@ -363,9 +363,20 @@ 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):** the same-module-vs-split curves above. Remaining build - question is W7 — can we auto-split an arbitrary target without the user - restructuring their package (access-level visibility, symbol override). +- **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`). Soft spots: S2 symbol + override cited from the jit-poc witness POC, not re-run end-to-end, and the + real-world frequency of break-case (1) is unmeasured — both covered by the + integrated POC (split → `@testable` compile → JIT-link → render), assigned. ### G2 — A file-identifying FileWatcher **Missing.** `FileWatcher` signals only "something in the watched set changed", @@ -375,12 +386,15 @@ to recompile. The watch scope is already ~"project sources minus dependencies" - **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. -### Model mismatch to resolve +### 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 slow full-rebuild path. "Any edited file reloads sub-second" is -a *stronger* guarantee than the design currently makes and needs G1+G2. Decide -whether Phase 3/4 targets only preview-layer edits or true any-file incremental. +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`; @@ -402,8 +416,11 @@ 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`. Modeling it is W6. **Open:** W7 (auto-split -feasibility) is now the critical path. +this project's `DesignTimeStore`. Modeling it is W6 (queued). **W7 — CLOSED:** +auto-split is feasible for the common case (matrix + break-cases in G1). +**Open:** the integrated POC (split → `@testable` compile → JIT-link → render), +assigned to research — it closes W7's two soft spots and is the bridge into +P3.4. ## Phase 3 status: IN PROGRESS From a1438512cc14731c785a03d5a7a42b791ad48468 Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Wed, 3 Jun 2026 22:28:13 -0400 Subject: [PATCH 16/46] Phase 3 plan: P3.4c protocol-seam split + defer JIT-in-CI as infra (#189) Records the agreed daemon seam: a StructuralReloader protocol in PreviewsCore (JIT-free), implemented by PreviewsJITLink, composed at the executable, injected only when jitEnabled. Chosen on layering merit, not to appease CI; the #if alternative was the actual workaround. Splits P3.4c into c-i (protocol seam + structural->agent snapshot) and c-ii (literal-after-structural). Logs JIT-in-CI (cache the prebuilt LLVM artifact so CI can run the JIT path) as a deferred infra task, separate from the architecture. Co-Authored-By: Claude Opus 4.8 --- docs/jit-executor-phase3-plan.md | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/docs/jit-executor-phase3-plan.md b/docs/jit-executor-phase3-plan.md index 3626f76..d5b8220 100644 --- a/docs/jit-executor-phase3-plan.md +++ b/docs/jit-executor-phase3-plan.md @@ -304,9 +304,30 @@ moves to the agent bitmap; interaction is untouched. orphan agents. Commit pending. - **P3.4c — daemon seam (U-D).** Route structural edits to recompile → respawn → agent-render → serve `preview_snapshot`; literal path - unchanged. + 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. + - **P3.4c-ii — literal-after-structural.** Once a session is agent-rendered the + view lives in the agent, so a later literal edit must re-seed the agent's + `DesignTimeStore` and re-render, not use the in-daemon path. Falls back to a + structural-style respawn until built. - **P3.4d — latency (U-C).** Measure structural respawn vs the <200ms target. +**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. From b5783da22801ff0ddcd68ad981723e52db578ddc Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Wed, 3 Jun 2026 22:42:26 -0400 Subject: [PATCH 17/46] docs/jit-executor-phase3-plan: integrated POC PASSED; soak gates executor shape (#189) Fold in the integrated POC result (cfe9bda on previews-research): the full split -> @testable single-file compile -> JIT-link -> render chain is proven end-to-end (v1 red -> v2 blue), closing W7's two soft spots. Numbers: ~233ms edit->pixels with respawn semantics vs ~167ms with a persistent agent + fresh-JD-per-edit. Record the load-bearing findings (LLJIT::initialize per generation registers __swift5_* metadata; ObjCSelrefPlugin + ExecutorNativePlatform required) and the file-granularity split rule that makes break-case 1 impossible by construction. The persistent shape conflicts with respawn-first's leak rationale, so a generation-soak (assigned) gates the decision; respawn-first stands until it lands. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/jit-executor-phase3-plan.md | 34 +++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/docs/jit-executor-phase3-plan.md b/docs/jit-executor-phase3-plan.md index d5b8220..61ef721 100644 --- a/docs/jit-executor-phase3-plan.md +++ b/docs/jit-executor-phase3-plan.md @@ -394,10 +394,22 @@ justification. 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`). Soft spots: S2 symbol - override cited from the jit-poc witness POC, not re-run end-to-end, and the - real-world frequency of break-case (1) is unmeasured — both covered by the - integrated POC (split → `@testable` compile → JIT-link → render), assigned. + (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", @@ -439,9 +451,17 @@ assumption; our respawn-first decision is unaffected (only the rationale changes (`__designTimeString`/`Integer` + fallback), not recompilation — the same idea as this project's `DesignTimeStore`. Modeling it is W6 (queued). **W7 — CLOSED:** auto-split is feasible for the common case (matrix + break-cases in G1). -**Open:** the integrated POC (split → `@testable` compile → JIT-link → render), -assigned to research — it closes W7's two soft spots and is the bridge into -P3.4. +**Integrated POC — PASSED** (`cfe9bda`): the full split → `@testable` → +JIT-link → render chain is proven; numbers and findings in G1. +**Open:** the **generation-soak** (assigned) — decides persistent-agent + +fresh-JD-per-edit (~167ms) vs respawn-per-edit (~233ms). The persistent shape +conflicts with the respawn-first rationale: each generation's +`LLJIT::initialize` permanently registers `__swift5_*` metadata (no deregister), +so conformance scans and RSS may grow with generation count. **Respawn-first +stays the standing decision** until the soak shows the leak is bounded (latency +flat, RSS bounded over ~500 generations); if it is, adopt persistent-agent with +a generation cap + periodic background respawn. W6 (design-time injection) +queued after. ## Phase 3 status: IN PROGRESS From c5cd096b78a1f0d2310ca5c3b43093f46186f48d Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Wed, 3 Jun 2026 22:46:57 -0400 Subject: [PATCH 18/46] docs/jit-executor-phase3-plan: amend respawn-first to respawn-on-cap (#189) Generation-soak (500 gens, previews-research) ratifies the executor shape: capped-persistent. Latency is flat (conformance scans do not slow) but RSS leaks ~87KB/generation since __swift5_* metadata cannot deregister, so one persistent agent + fresh JITDylib per edit with a background respawn every ~100 generations bounds the leak at ~+9MB and amortizes warmup to ~0.7ms/edit (~167ms vs ~233ms per edit). Respawn stays the cleanup mechanism, on the cap instead of every edit. Record the soak data in the evidence section. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/jit-executor-phase3-plan.md | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/docs/jit-executor-phase3-plan.md b/docs/jit-executor-phase3-plan.md index 61ef721..3c8e36a 100644 --- a/docs/jit-executor-phase3-plan.md +++ b/docs/jit-executor-phase3-plan.md @@ -28,9 +28,19 @@ 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 (agreed): respawn-first - -Updates use the **agent-respawn** model, not in-place `write_mem` patching. +## 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 @@ -453,15 +463,13 @@ this project's `DesignTimeStore`. Modeling it is W6 (queued). **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. -**Open:** the **generation-soak** (assigned) — decides persistent-agent + -fresh-JD-per-edit (~167ms) vs respawn-per-edit (~233ms). The persistent shape -conflicts with the respawn-first rationale: each generation's -`LLJIT::initialize` permanently registers `__swift5_*` metadata (no deregister), -so conformance scans and RSS may grow with generation count. **Respawn-first -stays the standing decision** until the soak shows the leak is bounded (latency -flat, RSS bounded over ~500 generations); if it is, adopt persistent-agent with -a generation cap + periodic background respawn. W6 (design-time injection) -queued after. +**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 (design-time injection) is +the last queued research item. ## Phase 3 status: IN PROGRESS From 0ab1e6776e32c9b53c36b86ed2abc5e03e02fe6c Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Wed, 3 Jun 2026 22:51:02 -0400 Subject: [PATCH 19/46] JIT executor Phase 3 (P3.4c-i-1): StructuralReloader seam in PreviewsCore (#189) The daemon seam for routing structural edits to the JIT path, kept in the JIT-free base module so the daemon depends on the abstraction, not the gated JIT target (chosen on layering merit; non-JIT build compiles by construction). - StructuralReloader protocol: renderObject(at:entrySymbol:), agnostic to respawn-vs-capped-persistent so the executor-shape choice swaps under the impl without touching Core or the daemon. - PreviewSession.compileObjectForJIT(): generates a render bridge with a baked PNG path, compiles a .o, returns a JITRenderBuild (object + image path + entry symbol). Standalone combined-source mode for now. Test (mock reloader) asserts the .o exports _renderPreviewToFile and the plumbing routes. 305/305 PreviewsCore green. Co-Authored-By: Claude Opus 4.8 --- Sources/PreviewsCore/PreviewSession.swift | 50 ++++++++++++++ Sources/PreviewsCore/StructuralReloader.swift | 14 ++++ .../StructuralReloaderTests.swift | 68 +++++++++++++++++++ docs/jit-executor-phase3-plan.md | 14 ++++ 4 files changed, 146 insertions(+) create mode 100644 Sources/PreviewsCore/StructuralReloader.swift create mode 100644 Tests/PreviewsCoreTests/StructuralReloaderTests.swift diff --git a/Sources/PreviewsCore/PreviewSession.swift b/Sources/PreviewsCore/PreviewSession.swift index 26edc46..fda7719 100644 --- a/Sources/PreviewsCore/PreviewSession.swift +++ b/Sources/PreviewsCore/PreviewSession.swift @@ -6,6 +6,14 @@ 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. +public struct JITRenderBuild: Sendable { + public let objectPath: URL + public let imagePath: URL + public let entrySymbol: String +} + /// Orchestrates the full preview pipeline: parse → generate bridge → compile → return dylib path. public actor PreviewSession { public nonisolated let id: String @@ -147,6 +155,48 @@ 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. Standalone (combined-source) mode only for now. + 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 imagePath = FileManager.default.temporaryDirectory + .appendingPathComponent("previewsmcp-jit-\(id)-\(UUID().uuidString).png") + + let generated = BridgeGenerator.generateCombinedSource( + originalSource: source, + closureBody: preview.closureBody, + previewIndex: previewIndex, + platform: platform, + traits: traits, + renderOutputPath: imagePath.path + ) + + let objectPath = try await compiler.compileObject( + source: generated.source, + moduleName: Self.moduleName(for: sourceFile) + ) + + lastOriginalSource = source + lastLiterals = generated.literals + + return JITRenderBuild( + objectPath: objectPath, + imagePath: imagePath, + entrySymbol: "renderPreviewToFile" + ) + } + /// 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). diff --git a/Sources/PreviewsCore/StructuralReloader.swift b/Sources/PreviewsCore/StructuralReloader.swift new file mode 100644 index 0000000..a9fba8f --- /dev/null +++ b/Sources/PreviewsCore/StructuralReloader.swift @@ -0,0 +1,14 @@ +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 { + func renderObject(at objectPath: URL, entrySymbol: String) async throws +} diff --git a/Tests/PreviewsCoreTests/StructuralReloaderTests.swift b/Tests/PreviewsCoreTests/StructuralReloaderTests.swift new file mode 100644 index 0000000..8c89d8c --- /dev/null +++ b/Tests/PreviewsCoreTests/StructuralReloaderTests.swift @@ -0,0 +1,68 @@ +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 renderObject(at objectPath: URL, entrySymbol: String) async throws { + calls.append((objectPath: objectPath, entrySymbol: 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.renderObject(at: build.objectPath, entrySymbol: build.entrySymbol) + let calls = await reloader.recorded() + #expect(calls.count == 1) + #expect(calls.first?.objectPath == build.objectPath) + #expect(calls.first?.entrySymbol == "renderPreviewToFile") + } + + 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/docs/jit-executor-phase3-plan.md b/docs/jit-executor-phase3-plan.md index 3c8e36a..4317bb2 100644 --- a/docs/jit-executor-phase3-plan.md +++ b/docs/jit-executor-phase3-plan.md @@ -324,6 +324,20 @@ moves to the agent bitmap; interaction is untouched. 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`** over + `JITSession`; a JIT-level test drives a real `compileObjectForJIT` `.o` + through it to a green PNG. + - **c-i-3 — host wiring**: inject at the composition root, branch + `watchFile`'s structural path, reroute `preview_snapshot` for agent-backed + sessions. - **P3.4c-ii — literal-after-structural.** Once a session is agent-rendered the view lives in the agent, so a later literal edit must re-seed the agent's `DesignTimeStore` and re-render, not use the in-daemon path. Falls back to a From c448d6f937aaf04c161542b93ae08444b3c6a37e Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Wed, 3 Jun 2026 22:53:19 -0400 Subject: [PATCH 20/46] JIT executor Phase 3 (P3.4c-i-2): JITStructuralReloader over the agent (#189) Implements StructuralReloader in PreviewsJITLink: spawn a fresh agent, addObject, runOnMain(entrySymbol) which writes the preview PNG to the baked path; throw on non-zero status. Respawn-per-edit via JITSession deinit (a capped-persistent variant can replace the body without touching the protocol). Adds PreviewsCore as a PreviewsJITLink dependency (the JIT module now implements a Core protocol; also resolves a transitive _SwiftSyntaxCShims module error). Inside the jitEnabled block, so the non-JIT build is unaffected. Test drives a real PreviewSession.compileObjectForJIT() .o through the reloader and asserts the agent-written PNG is green. 32/32 JIT green, 3/3 parallel, zero orphans. Co-Authored-By: Claude Opus 4.8 --- Package.swift | 2 +- .../JITStructuralReloader.swift | 31 ++++++++++++ .../JITStructuralReloaderTests.swift | 47 +++++++++++++++++++ docs/jit-executor-phase3-plan.md | 12 +++-- 4 files changed, 88 insertions(+), 4 deletions(-) create mode 100644 Sources/PreviewsJITLink/JITStructuralReloader.swift create mode 100644 Tests/PreviewsJITLinkTests/JITStructuralReloaderTests.swift diff --git a/Package.swift b/Package.swift index 317a62d..4070acb 100644 --- a/Package.swift +++ b/Package.swift @@ -117,7 +117,7 @@ if jitEnabled { targets += [ .target( name: "PreviewsJITLink", - dependencies: ["PreviewsJITLinkCxx"], + dependencies: ["PreviewsJITLinkCxx", "PreviewsCore"], plugins: [.plugin(name: "BundleOrcRuntime")] ), .target( diff --git a/Sources/PreviewsJITLink/JITStructuralReloader.swift b/Sources/PreviewsJITLink/JITStructuralReloader.swift new file mode 100644 index 0000000..896517b --- /dev/null +++ b/Sources/PreviewsJITLink/JITStructuralReloader.swift @@ -0,0 +1,31 @@ +import Foundation +import PreviewsCore + +/// `StructuralReloader` backed by the remote JIT agent. Spawns a fresh agent, links the +/// object, and runs its render entry on the agent's main thread; the entry writes the +/// preview PNG to the path baked into the object at compile time. The session's `deinit` +/// kills the agent, so this is respawn-per-edit; a capped-persistent variant can replace +/// the body without changing the protocol. +public struct JITStructuralReloader: StructuralReloader { + public init() {} + + public func renderObject(at objectPath: URL, entrySymbol: String) async throws { + let session = try JITSession(remoteAgentPath: JITSession.bundledAgentPath()) + try session.addObject(path: objectPath.path) + let status = try session.runOnMain(symbol: entrySymbol) + guard status == 0 else { + throw JITReloadError.renderFailed(status: status) + } + } +} + +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/Tests/PreviewsJITLinkTests/JITStructuralReloaderTests.swift b/Tests/PreviewsJITLinkTests/JITStructuralReloaderTests.swift new file mode 100644 index 0000000..896330a --- /dev/null +++ b/Tests/PreviewsJITLinkTests/JITStructuralReloaderTests.swift @@ -0,0 +1,47 @@ +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.renderObject(at: build.objectPath, entrySymbol: build.entrySymbol) + + 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) + } +} diff --git a/docs/jit-executor-phase3-plan.md b/docs/jit-executor-phase3-plan.md index 4317bb2..9fc8f08 100644 --- a/docs/jit-executor-phase3-plan.md +++ b/docs/jit-executor-phase3-plan.md @@ -332,9 +332,15 @@ moves to the agent bitmap; interaction is untouched. (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`** over - `JITSession`; a JIT-level test drives a real `compileObjectForJIT` `.o` - through it to a green PNG. + - **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**: inject at the composition root, branch `watchFile`'s structural path, reroute `preview_snapshot` for agent-backed sessions. From ebac13a70a965f75f69a0b270e6c65b82d87f206 Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Wed, 3 Jun 2026 22:54:04 -0400 Subject: [PATCH 21/46] docs/jit-executor-phase3-plan: W6 closed; record the executor edit-tier model (#189) W6 (previews-research) closes the research arc (W3-W7 all done). Canvas-is-split refinement: Apple's canvas thunk compile is single -primary-file + -vfsoverlay + explicit module map with NO filelist/incremental, i.e. already the W5/W7 split shape. Design-time injection lifecycle modeled (#salt_n IDs -> value table -> UpdatePayload re-injection, no recompile/respawn for literal edits); our DesignTimeStore mirrors it, boundary = LiteralDiffer skeleton-equality incl. the UIKit downgrade (#160). Record the three-tier executor model: literal SwiftUI = value push; structural = split compile + JIT-link (capped-persistent); UIKit literal = tier 2. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/jit-executor-phase3-plan.md | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/docs/jit-executor-phase3-plan.md b/docs/jit-executor-phase3-plan.md index 9fc8f08..0628293 100644 --- a/docs/jit-executor-phase3-plan.md +++ b/docs/jit-executor-phase3-plan.md @@ -479,7 +479,7 @@ 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`. Modeling it is W6 (queued). **W7 — CLOSED:** +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. @@ -488,8 +488,33 @@ 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 (design-time injection) is -the last queued research item. +(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: IN PROGRESS From 32c113c142eed003b7fe2806666eb9bf570db438 Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Wed, 3 Jun 2026 23:27:20 -0400 Subject: [PATCH 22/46] JIT executor Phase 3 (P3.4c-i-3): wire the JIT structural reload into the daemon (#189) Routes structural edits through the agent when a StructuralReloader is injected, while the literal fast path and the in-daemon dylib path are untouched. - PreviewHost: structuralReloader: (any StructuralReloader)? + an agentImagePaths map; jitStructuralReload() compiles the .o, renders in the agent, records the PNG. watchFile's structural branch prefers it when injected, else the existing compile()+loadPreview() dylib path. - MacOSPreviewHandle.snapshot serves the agent PNG for agent-backed sessions (new Snapshot.encode: PNG passthrough / JPEG transcode). - 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 (depends only on the Core protocol); the single conditional lives at the app entry where composition belongs. Tests: PreviewHostJITReloadTests (records the agent image; nil reloader falls through) + MacOSPreviewHandleAgentSnapshotTests (snapshot returns the agent PNG). macOS 3 / engine 9 / JIT 32 green; full JIT build green. Co-Authored-By: Claude Opus 4.8 --- Package.swift | 9 ++- Sources/PreviewsCLI/PreviewsMCPApp.swift | 7 ++ .../PreviewsEngine/MacOSPreviewHandle.swift | 3 + Sources/PreviewsMacOS/HostApp.swift | 35 ++++++++ Sources/PreviewsMacOS/Snapshot.swift | 22 +++++ ...MacOSPreviewHandleAgentSnapshotTests.swift | 80 +++++++++++++++++++ .../PreviewHostJITReloadTests.swift | 69 ++++++++++++++++ docs/jit-executor-phase3-plan.md | 19 ++++- 8 files changed, 240 insertions(+), 4 deletions(-) create mode 100644 Tests/PreviewsEngineTests/MacOSPreviewHandleAgentSnapshotTests.swift create mode 100644 Tests/PreviewsMacOSTests/PreviewHostJITReloadTests.swift diff --git a/Package.swift b/Package.swift index 4070acb..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( diff --git a/Sources/PreviewsCLI/PreviewsMCPApp.swift b/Sources/PreviewsCLI/PreviewsMCPApp.swift index d1f80ec..287fd39 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/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/PreviewsMacOS/HostApp.swift b/Sources/PreviewsMacOS/HostApp.swift index 52dd61e..996f5b1 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. @@ -206,6 +215,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 +299,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 +334,23 @@ 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.renderObject(at: build.objectPath, entrySymbol: build.entrySymbol) + 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] + } + /// 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/PreviewsEngineTests/MacOSPreviewHandleAgentSnapshotTests.swift b/Tests/PreviewsEngineTests/MacOSPreviewHandleAgentSnapshotTests.swift new file mode 100644 index 0000000..212825e --- /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 renderObject(at objectPath: URL, entrySymbol: String) 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/PreviewsMacOSTests/PreviewHostJITReloadTests.swift b/Tests/PreviewsMacOSTests/PreviewHostJITReloadTests.swift new file mode 100644 index 0000000..22f97b3 --- /dev/null +++ b/Tests/PreviewsMacOSTests/PreviewHostJITReloadTests.swift @@ -0,0 +1,69 @@ +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 renderObject(at objectPath: URL, entrySymbol: String) async throws { + calls.append((objectPath: objectPath, entrySymbol: 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 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/docs/jit-executor-phase3-plan.md b/docs/jit-executor-phase3-plan.md index 0628293..259c686 100644 --- a/docs/jit-executor-phase3-plan.md +++ b/docs/jit-executor-phase3-plan.md @@ -341,9 +341,22 @@ moves to the agent bitmap; interaction is untouched. `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**: inject at the composition root, branch - `watchFile`'s structural path, reroute `preview_snapshot` for agent-backed - sessions. + - **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 a later literal edit must re-seed the agent's `DesignTimeStore` and re-render, not use the in-daemon path. Falls back to a From d54d4a26f11da8b5ca98f6643ed8cf68244e846d Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Wed, 3 Jun 2026 23:33:10 -0400 Subject: [PATCH 23/46] JIT executor Phase 3 (P3.4c-ii): route agent-backed literal edits to the JIT path (#189) Once a session is agent-rendered its view lives in the agent, so the in-daemon DesignTimeStore literal path can no longer update it (applyLiteralChanges no-ops without a loader). watchFile now gates the literal fast path on agentSnapshotPath == nil, so an agent-backed session routes any edit through the structural JIT reload. Correct, not the ~10ms path; the proper agent-side DesignTimeStore re-seed is deferred (needs an agent setter surface). Co-Authored-By: Claude Opus 4.8 --- Sources/PreviewsMacOS/HostApp.swift | 11 +++++++++-- docs/jit-executor-phase3-plan.md | 15 +++++++++++---- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/Sources/PreviewsMacOS/HostApp.swift b/Sources/PreviewsMacOS/HostApp.swift index 996f5b1..3b0059f 100644 --- a/Sources/PreviewsMacOS/HostApp.swift +++ b/Sources/PreviewsMacOS/HostApp.swift @@ -189,8 +189,15 @@ public class PreviewHost: NSObject, NSApplicationDelegate { return } - // Fast path: try literal-only update - if let currentSession = await MainActor.run(body: { self.sessions[sessionID] }), + // Fast path: try literal-only update. Agent-backed sessions (already on + // the JIT path) skip it — their view lives in the agent, not an in-daemon + // dylib, so a literal edit re-renders through the structural JIT path + // below until the agent-side DesignTimeStore re-seed (P3.4c-ii) lands. + let agentBacked = await MainActor.run { + self.agentSnapshotPath(for: sessionID) != nil + } + if !agentBacked, + let currentSession = await MainActor.run(body: { self.sessions[sessionID] }), let changes = await currentSession.tryLiteralUpdate(newSource: newSource), !changes.isEmpty { diff --git a/docs/jit-executor-phase3-plan.md b/docs/jit-executor-phase3-plan.md index 259c686..1c7bb30 100644 --- a/docs/jit-executor-phase3-plan.md +++ b/docs/jit-executor-phase3-plan.md @@ -357,10 +357,17 @@ moves to the agent bitmap; interaction is untouched. 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 a later literal edit must re-seed the agent's - `DesignTimeStore` and re-render, not use the in-daemon path. Falls back to a - structural-style respawn until built. + - **P3.4c-ii — literal-after-structural — DONE (fallback).** 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). `watchFile` now gates the literal fast path on + `agentSnapshotPath(for:) == nil`, so an agent-backed session routes **any** + edit through the structural JIT reload (re-renders in the agent). Correct but + not the ~10ms path. The **proper** cheap path — re-seed the agent's + `DesignTimeStore` over the wire and re-render without recompiling — is deferred + (needs an agent setter surface). The routing lives in the `FileWatcher` + closure (not unit-tested); covered by the deferred `examples/` E2E. *Verify + (met):* full `swift build` green, macOS/engine/JIT suites green. - **P3.4d — latency (U-C).** Measure structural respawn vs the <200ms target. **Deferred infra (separate from architecture): JIT-in-CI.** CI skips the JIT From c689a16030fc459dd17c92b7d0b421459d18774d Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Wed, 3 Jun 2026 23:34:32 -0400 Subject: [PATCH 24/46] JIT executor Phase 3 (P3.4d): measure structural reload latency (#189) Integrated compileObjectForJIT() + JITStructuralReloader.renderObject() on a small module, steady-state, respawn-per-edit: compile ~218ms, link+respawn+agent-render ~62ms, total ~281ms. The render/respawn half is well within the <200ms budget; the whole-module compile dominates and is the entire gap. Closing it needs the deferred recompile-narrowing (stable-module/editable-unit split), a compile-side lever, not a JIT-executor change. Measurement test prints the breakdown and asserts a loose 5s regression tripwire. Co-Authored-By: Claude Opus 4.8 --- .../StructuralReloadLatencyTests.swift | 58 +++++++++++++++++++ docs/jit-executor-phase3-plan.md | 15 ++++- 2 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 Tests/PreviewsJITLinkTests/StructuralReloadLatencyTests.swift diff --git a/Tests/PreviewsJITLinkTests/StructuralReloadLatencyTests.swift b/Tests/PreviewsJITLinkTests/StructuralReloadLatencyTests.swift new file mode 100644 index 0000000..586c7f1 --- /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.renderObject(at: warm.objectPath, entrySymbol: warm.entrySymbol) + + let clock = ContinuousClock() + let t0 = clock.now + let build = try await session.compileObjectForJIT() + let t1 = clock.now + try await reloader.renderObject(at: build.objectPath, entrySymbol: build.entrySymbol) + 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 < 5000) + } +} diff --git a/docs/jit-executor-phase3-plan.md b/docs/jit-executor-phase3-plan.md index 1c7bb30..b04babe 100644 --- a/docs/jit-executor-phase3-plan.md +++ b/docs/jit-executor-phase3-plan.md @@ -368,7 +368,20 @@ moves to the agent bitmap; interaction is untouched. (needs an agent setter surface). The routing lives in the `FileWatcher` closure (not unit-tested); covered by the deferred `examples/` E2E. *Verify (met):* full `swift build` green, macOS/engine/JIT suites green. -- **P3.4d — latency (U-C).** Measure structural respawn vs the <200ms target. +- **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 From 38f8745d73e6a3c6e8225837d45748b4dbe01d06 Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Thu, 4 Jun 2026 00:09:55 -0400 Subject: [PATCH 25/46] JIT executor Phase 3 (P3.4c-ii-1): seed DesignTimeStore from a JSON file in the agent (#189) Proper literal-after-structural path, step 1. The agent runs only nullary entries, so rather than call the designTimeSet* setters over the wire, the render bridge seeds DesignTimeStore.shared.values from a baked JSON path before building the view (value file-transport, mirroring the PNG choice). compileObjectForJIT bakes the path and writes the literals' initial values; JITRenderBuild gains valuesPath + literals. This sets up the no-recompile literal re-render (c-ii-2): rewrite the JSON and re-run renderPreviewToFile on the same .o. Test asserts the values JSON is written with the literals; existing agent render tests still produce the correct color through the seeded path. JIT 33 / BridgeGenerator 69 / PreviewsCore 306 green. Co-Authored-By: Claude Opus 4.8 --- Sources/PreviewsCore/BridgeGenerator.swift | 60 ++++++++++++------- Sources/PreviewsCore/PreviewSession.swift | 33 +++++++++- .../StructuralReloaderTests.swift | 34 +++++++++++ docs/jit-executor-phase3-plan.md | 37 ++++++++---- 4 files changed, 129 insertions(+), 35 deletions(-) diff --git a/Sources/PreviewsCore/BridgeGenerator.swift b/Sources/PreviewsCore/BridgeGenerator.swift index b803961..e33a7e9 100644 --- a/Sources/PreviewsCore/BridgeGenerator.swift +++ b/Sources/PreviewsCore/BridgeGenerator.swift @@ -22,7 +22,8 @@ public enum BridgeGenerator { traits: PreviewTraits = PreviewTraits(), setupModule: String? = nil, setupType: String? = nil, - renderOutputPath: String? = nil + renderOutputPath: String? = nil, + designTimeValuesPath: String? = nil ) -> (source: String, literals: [LiteralEntry]) { // Transform source to replace literals with DesignTimeStore lookups let thunkResult = ThunkGenerator.transform(source: originalSource) @@ -55,7 +56,10 @@ public enum BridgeGenerator { let bodyKindEntry = bodyKindEntryPoint(closureBody: transformedClosureBody) let renderEntry = - renderOutputPath.map { renderToFileEntryPoint(viewCode: viewCode, path: $0) } ?? "" + renderOutputPath.map { + renderToFileEntryPoint( + viewCode: viewCode, path: $0, valuesPath: designTimeValuesPath) + } ?? "" let bridgeCode: String switch platform { case .macOS: @@ -255,28 +259,42 @@ public enum BridgeGenerator { /// 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) -> String { - """ - @_cdecl("renderPreviewToFile") - public func renderPreviewToFile() -> Int32 { - MainActor.assumeIsolated { - 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) + 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 } - do { - try data.write(to: URL(fileURLWithPath: "\(path)")) - } catch { - return Int32(-3) + """ + } ?? "" + 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 } - return 0 } - } - """ + """ } /// Generate the `@_cdecl("previewSetUp")` entry point that bridges async setUp. diff --git a/Sources/PreviewsCore/PreviewSession.swift b/Sources/PreviewsCore/PreviewSession.swift index fda7719..4435df3 100644 --- a/Sources/PreviewsCore/PreviewSession.swift +++ b/Sources/PreviewsCore/PreviewSession.swift @@ -8,10 +8,15 @@ public struct CompileResult: Sendable { /// 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] } /// Orchestrates the full preview pipeline: parse → generate bridge → compile → return dylib path. @@ -170,8 +175,11 @@ public actor PreviewSession { } let preview = previews[previewIndex] + let stem = "previewsmcp-jit-\(id)-\(UUID().uuidString)" let imagePath = FileManager.default.temporaryDirectory - .appendingPathComponent("previewsmcp-jit-\(id)-\(UUID().uuidString).png") + .appendingPathComponent("\(stem).png") + let valuesPath = FileManager.default.temporaryDirectory + .appendingPathComponent("\(stem).json") let generated = BridgeGenerator.generateCombinedSource( originalSource: source, @@ -179,13 +187,15 @@ public actor PreviewSession { previewIndex: previewIndex, platform: platform, traits: traits, - renderOutputPath: imagePath.path + renderOutputPath: imagePath.path, + designTimeValuesPath: valuesPath.path ) let objectPath = try await compiler.compileObject( source: generated.source, moduleName: Self.moduleName(for: sourceFile) ) + try Self.writeDesignTimeValues(generated.literals, to: valuesPath) lastOriginalSource = source lastLiterals = generated.literals @@ -193,10 +203,27 @@ public actor PreviewSession { return JITRenderBuild( objectPath: objectPath, imagePath: imagePath, - entrySymbol: "renderPreviewToFile" + valuesPath: valuesPath, + entrySymbol: "renderPreviewToFile", + literals: generated.literals ) } + /// 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 { + switch entry.value { + case .string(let s): dict[entry.id] = s + case .integer(let n): dict[entry.id] = n + case .float(let d): dict[entry.id] = d + case .boolean(let b): dict[entry.id] = b + } + } + let data = try JSONSerialization.data(withJSONObject: dict) + try data.write(to: path) + } + /// 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). diff --git a/Tests/PreviewsCoreTests/StructuralReloaderTests.swift b/Tests/PreviewsCoreTests/StructuralReloaderTests.swift index 8c89d8c..1c9eb12 100644 --- a/Tests/PreviewsCoreTests/StructuralReloaderTests.swift +++ b/Tests/PreviewsCoreTests/StructuralReloaderTests.swift @@ -54,6 +54,40 @@ struct StructuralReloaderTests { #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") diff --git a/docs/jit-executor-phase3-plan.md b/docs/jit-executor-phase3-plan.md index b04babe..4fb329c 100644 --- a/docs/jit-executor-phase3-plan.md +++ b/docs/jit-executor-phase3-plan.md @@ -357,17 +357,32 @@ moves to the agent bitmap; interaction is untouched. 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 — DONE (fallback).** 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). `watchFile` now gates the literal fast path on - `agentSnapshotPath(for:) == nil`, so an agent-backed session routes **any** - edit through the structural JIT reload (re-renders in the agent). Correct but - not the ~10ms path. The **proper** cheap path — re-seed the agent's - `DesignTimeStore` over the wire and re-render without recompiling — is deferred - (needs an agent setter surface). The routing lives in the `FileWatcher` - closure (not unit-tested); covered by the deferred `examples/` E2E. *Verify - (met):* full `swift build` green, macOS/engine/JIT suites green. + - **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 — TODO.** Rewrite the JSON to new values, `renderObject` the same + `.o`, assert the pixel changed with no recompile. + - **c-ii-3 — TODO.** `PreviewSession` caches the last `JITRenderBuild`; + agent-backed literal edits rewrite values + re-render instead of the + fallback full reload. - **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: From 728e14baf202a3dcb825f3ebd8bc24377f65f114 Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Thu, 4 Jun 2026 00:11:44 -0400 Subject: [PATCH 26/46] JIT executor Phase 3 (P3.4c-ii-2): no-recompile literal re-render via JSON rewrite (#189) Proves the cheap literal path: compile a Color(white: 0.2) preview once, render in the agent (dark), rewrite the baked design-time values JSON to set the white literal to 0.9, then re-run renderPreviewToFile on the SAME .o. Brightness flips dark->light with no second compile. Test only. Co-Authored-By: Claude Opus 4.8 --- .../JITStructuralReloaderTests.swift | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/Tests/PreviewsJITLinkTests/JITStructuralReloaderTests.swift b/Tests/PreviewsJITLinkTests/JITStructuralReloaderTests.swift index 896330a..cca1f6f 100644 --- a/Tests/PreviewsJITLinkTests/JITStructuralReloaderTests.swift +++ b/Tests/PreviewsJITLinkTests/JITStructuralReloaderTests.swift @@ -44,4 +44,61 @@ struct JITStructuralReloaderTests { #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.renderObject(at: build.objectPath, entrySymbol: build.entrySymbol) + 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.renderObject(at: build.objectPath, entrySymbol: build.entrySymbol) + 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 + } } From 859eed3f0ad2c20f308a4ef5809e8178262857c1 Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Thu, 4 Jun 2026 00:13:58 -0400 Subject: [PATCH 27/46] JIT executor Phase 3 (P3.4c-ii-3): wire the no-recompile literal path into the daemon (#189) Completes the proper literal-after-structural path. PreviewSession caches the last JITRenderBuild and adds applyLiteralValuesForJIT(), which merges the changed literals into the design-time values JSON and returns the build to re-render. PreviewHost.jitLiteralReload() rewrites the values and re-renders the same object in the agent. watchFile's literal branch now routes agent-backed sessions there (no recompile) while non-agent sessions keep the in-daemon DesignTimeStore path. Test: jitLiteralReload rewrites the values JSON (hello -> world) and records the image. PreviewsCore 306 / macOS 4 / engine 9 / JIT 34 green. Co-Authored-By: Claude Opus 4.8 --- Sources/PreviewsCore/PreviewSession.swift | 39 +++++++++++++--- Sources/PreviewsMacOS/HostApp.swift | 38 ++++++++++++--- .../PreviewHostJITReloadTests.swift | 46 +++++++++++++++++++ docs/jit-executor-phase3-plan.md | 19 ++++++-- 4 files changed, 124 insertions(+), 18 deletions(-) diff --git a/Sources/PreviewsCore/PreviewSession.swift b/Sources/PreviewsCore/PreviewSession.swift index 4435df3..b400006 100644 --- a/Sources/PreviewsCore/PreviewSession.swift +++ b/Sources/PreviewsCore/PreviewSession.swift @@ -36,6 +36,7 @@ public actor PreviewSession { private var compilationResult: CompilationResult? private var lastOriginalSource: String? private var lastLiterals: [LiteralEntry]? + private var lastJITBuild: JITRenderBuild? public enum State: Sendable { case idle @@ -200,30 +201,54 @@ public actor PreviewSession { lastOriginalSource = source lastLiterals = generated.literals - return JITRenderBuild( + let build = JITRenderBuild( objectPath: objectPath, imagePath: imagePath, valuesPath: valuesPath, entrySymbol: "renderPreviewToFile", literals: generated.literals ) + lastJITBuild = build + return build + } + + /// 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 { - switch entry.value { - case .string(let s): dict[entry.id] = s - case .integer(let n): dict[entry.id] = n - case .float(let d): dict[entry.id] = d - case .boolean(let b): dict[entry.id] = b - } + 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). diff --git a/Sources/PreviewsMacOS/HostApp.swift b/Sources/PreviewsMacOS/HostApp.swift index 3b0059f..444a0a6 100644 --- a/Sources/PreviewsMacOS/HostApp.swift +++ b/Sources/PreviewsMacOS/HostApp.swift @@ -189,19 +189,27 @@ public class PreviewHost: NSObject, NSApplicationDelegate { return } - // Fast path: try literal-only update. Agent-backed sessions (already on - // the JIT path) skip it — their view lives in the agent, not an in-daemon - // dylib, so a literal edit re-renders through the structural JIT path - // below until the agent-side DesignTimeStore re-seed (P3.4c-ii) lands. + // 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 !agentBacked, - let currentSession = await MainActor.run(body: { self.sessions[sessionID] }), + 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) } @@ -358,6 +366,24 @@ public class PreviewHost: NSObject, NSApplicationDelegate { 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.renderObject(at: build.objectPath, entrySymbol: build.entrySymbol) + 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/Tests/PreviewsMacOSTests/PreviewHostJITReloadTests.swift b/Tests/PreviewsMacOSTests/PreviewHostJITReloadTests.swift index 22f97b3..c57a235 100644 --- a/Tests/PreviewsMacOSTests/PreviewHostJITReloadTests.swift +++ b/Tests/PreviewsMacOSTests/PreviewHostJITReloadTests.swift @@ -50,6 +50,52 @@ struct PreviewHostJITReloadTests { #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 diff --git a/docs/jit-executor-phase3-plan.md b/docs/jit-executor-phase3-plan.md index 4fb329c..3935122 100644 --- a/docs/jit-executor-phase3-plan.md +++ b/docs/jit-executor-phase3-plan.md @@ -378,11 +378,20 @@ moves to the agent bitmap; interaction is untouched. 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 — TODO.** Rewrite the JSON to new values, `renderObject` the same - `.o`, assert the pixel changed with no recompile. - - **c-ii-3 — TODO.** `PreviewSession` caches the last `JITRenderBuild`; - agent-backed literal edits rewrite values + re-render instead of the - fallback full reload. + - **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: From 70e8f0d93e64129b5588f44b70d658b8c752b064 Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Thu, 4 Jun 2026 00:47:40 -0400 Subject: [PATCH 28/46] Fix swift-format lint --strict on Phase 3 P3.4 files (#189) Re-indent multiline string fixtures and the #if PREVIEWSMCP_JIT import to satisfy swift-format --strict (the repo .swift-format config, found via --recursive). Formatting only; PreviewsCore 306 / macOS 4 / engine 9 / JIT 34 green. Co-Authored-By: Claude Opus 4.8 --- Sources/PreviewsCLI/PreviewsMCPApp.swift | 4 +-- .../StructuralReloaderTests.swift | 36 +++++++++---------- ...MacOSPreviewHandleAgentSnapshotTests.swift | 18 +++++----- .../JITStructuralReloaderTests.swift | 36 +++++++++---------- .../StructuralReloadLatencyTests.swift | 18 +++++----- .../PreviewHostJITReloadTests.swift | 36 +++++++++---------- 6 files changed, 74 insertions(+), 74 deletions(-) diff --git a/Sources/PreviewsCLI/PreviewsMCPApp.swift b/Sources/PreviewsCLI/PreviewsMCPApp.swift index 287fd39..d796791 100644 --- a/Sources/PreviewsCLI/PreviewsMCPApp.swift +++ b/Sources/PreviewsCLI/PreviewsMCPApp.swift @@ -3,7 +3,7 @@ import ArgumentParser import PreviewsMacOS #if PREVIEWSMCP_JIT - import PreviewsJITLink +import PreviewsJITLink #endif /// Target platform for CLI commands. @@ -90,7 +90,7 @@ public struct PreviewsMCPApp { let app = NSApplication.shared let host = PreviewHost() #if PREVIEWSMCP_JIT - host.structuralReloader = JITStructuralReloader() + host.structuralReloader = JITStructuralReloader() #endif ServeCommand.sharedHost = host app.delegate = host diff --git a/Tests/PreviewsCoreTests/StructuralReloaderTests.swift b/Tests/PreviewsCoreTests/StructuralReloaderTests.swift index 1c9eb12..3e67bda 100644 --- a/Tests/PreviewsCoreTests/StructuralReloaderTests.swift +++ b/Tests/PreviewsCoreTests/StructuralReloaderTests.swift @@ -23,18 +23,18 @@ struct StructuralReloaderTests { let sourceFile = dir.appendingPathComponent("ColorView.swift") try """ - import SwiftUI + import SwiftUI - struct ColorView: View { - var body: some View { - Color(red: 0, green: 1, blue: 0).frame(width: 8, height: 8) - } + 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) + #Preview { + ColorView() + } + """.write(to: sourceFile, atomically: true, encoding: .utf8) let compiler = try await Compiler() let session = PreviewSession(sourceFile: sourceFile, compiler: compiler) @@ -63,18 +63,18 @@ struct StructuralReloaderTests { let sourceFile = dir.appendingPathComponent("HelloView.swift") try """ - import SwiftUI + import SwiftUI - struct HelloView: View { - var body: some View { - Text("hello") - } + struct HelloView: View { + var body: some View { + Text("hello") } + } - #Preview { - HelloView() - } - """.write(to: sourceFile, atomically: true, encoding: .utf8) + #Preview { + HelloView() + } + """.write(to: sourceFile, atomically: true, encoding: .utf8) let compiler = try await Compiler() let session = PreviewSession(sourceFile: sourceFile, compiler: compiler) diff --git a/Tests/PreviewsEngineTests/MacOSPreviewHandleAgentSnapshotTests.swift b/Tests/PreviewsEngineTests/MacOSPreviewHandleAgentSnapshotTests.swift index 212825e..4f2fcbb 100644 --- a/Tests/PreviewsEngineTests/MacOSPreviewHandleAgentSnapshotTests.swift +++ b/Tests/PreviewsEngineTests/MacOSPreviewHandleAgentSnapshotTests.swift @@ -22,18 +22,18 @@ struct MacOSPreviewHandleAgentSnapshotTests { let sourceFile = dir.appendingPathComponent("ColorView.swift") try """ - import SwiftUI + import SwiftUI - struct ColorView: View { - var body: some View { - Color(red: 0, green: 1, blue: 0).frame(width: 8, height: 8) - } + 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) + #Preview { + ColorView() + } + """.write(to: sourceFile, atomically: true, encoding: .utf8) let compiler = try await Compiler() let session = PreviewSession(sourceFile: sourceFile, compiler: compiler) diff --git a/Tests/PreviewsJITLinkTests/JITStructuralReloaderTests.swift b/Tests/PreviewsJITLinkTests/JITStructuralReloaderTests.swift index cca1f6f..0619de1 100644 --- a/Tests/PreviewsJITLinkTests/JITStructuralReloaderTests.swift +++ b/Tests/PreviewsJITLinkTests/JITStructuralReloaderTests.swift @@ -13,18 +13,18 @@ struct JITStructuralReloaderTests { let sourceFile = dir.appendingPathComponent("ColorView.swift") try """ - import SwiftUI + import SwiftUI - struct ColorView: View { - var body: some View { - Color(red: 0, green: 1, blue: 0).frame(width: 8, height: 8) - } + 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) + #Preview { + ColorView() + } + """.write(to: sourceFile, atomically: true, encoding: .utf8) let compiler = try await Compiler() let session = PreviewSession(sourceFile: sourceFile, compiler: compiler) @@ -53,18 +53,18 @@ struct JITStructuralReloaderTests { let sourceFile = dir.appendingPathComponent("GrayView.swift") try """ - import SwiftUI + import SwiftUI - struct GrayView: View { - var body: some View { - Color(white: 0.2).frame(width: 8, height: 8) - } + 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) + #Preview { + GrayView() + } + """.write(to: sourceFile, atomically: true, encoding: .utf8) let compiler = try await Compiler() let session = PreviewSession(sourceFile: sourceFile, compiler: compiler) diff --git a/Tests/PreviewsJITLinkTests/StructuralReloadLatencyTests.swift b/Tests/PreviewsJITLinkTests/StructuralReloadLatencyTests.swift index 586c7f1..04b9921 100644 --- a/Tests/PreviewsJITLinkTests/StructuralReloadLatencyTests.swift +++ b/Tests/PreviewsJITLinkTests/StructuralReloadLatencyTests.swift @@ -17,18 +17,18 @@ struct StructuralReloadLatencyTests { let sourceFile = dir.appendingPathComponent("ColorView.swift") try """ - import SwiftUI + import SwiftUI - struct ColorView: View { - var body: some View { - Color(red: 0, green: 1, blue: 0).frame(width: 8, height: 8) - } + 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) + #Preview { + ColorView() + } + """.write(to: sourceFile, atomically: true, encoding: .utf8) let compiler = try await Compiler() let session = PreviewSession(sourceFile: sourceFile, compiler: compiler) diff --git a/Tests/PreviewsMacOSTests/PreviewHostJITReloadTests.swift b/Tests/PreviewsMacOSTests/PreviewHostJITReloadTests.swift index c57a235..0fa603d 100644 --- a/Tests/PreviewsMacOSTests/PreviewHostJITReloadTests.swift +++ b/Tests/PreviewsMacOSTests/PreviewHostJITReloadTests.swift @@ -23,18 +23,18 @@ struct PreviewHostJITReloadTests { let sourceFile = dir.appendingPathComponent("ColorView.swift") try """ - import SwiftUI + import SwiftUI - struct ColorView: View { - var body: some View { - Color(red: 0, green: 1, blue: 0).frame(width: 8, height: 8) - } + 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) + #Preview { + ColorView() + } + """.write(to: sourceFile, atomically: true, encoding: .utf8) let compiler = try await Compiler() let session = PreviewSession(sourceFile: sourceFile, compiler: compiler) @@ -58,18 +58,18 @@ struct PreviewHostJITReloadTests { let sourceFile = dir.appendingPathComponent("HelloView.swift") try """ - import SwiftUI + import SwiftUI - struct HelloView: View { - var body: some View { - Text("hello") - } + struct HelloView: View { + var body: some View { + Text("hello") } + } - #Preview { - HelloView() - } - """.write(to: sourceFile, atomically: true, encoding: .utf8) + #Preview { + HelloView() + } + """.write(to: sourceFile, atomically: true, encoding: .utf8) let compiler = try await Compiler() let session = PreviewSession(sourceFile: sourceFile, compiler: compiler) From 02b3b08b6a09af0091b4220a7942ef8a752daa56 Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Thu, 4 Jun 2026 01:00:45 -0400 Subject: [PATCH 29/46] Phase 3 plan: P3.4 core complete; resume pointer for the next phase (#189) Mark Phase 3 core complete (P3.1/P3.2/P3.4 a-d landed; P3.3 conditional, examples E2E pending). Rewrite the resume pointer with the current tip, the P3.4 seam key-files map, and the next-phase priorities (recompile-narrowing first, then capped-persistent reloader, examples E2E, JIT-in-CI). Carry the swift-format --recursive lint pitfall. Co-Authored-By: Claude Opus 4.8 --- docs/jit-executor-phase3-plan.md | 87 ++++++++++++++++++++++++-------- 1 file changed, 65 insertions(+), 22 deletions(-) diff --git a/docs/jit-executor-phase3-plan.md b/docs/jit-executor-phase3-plan.md index 3935122..0c93697 100644 --- a/docs/jit-executor-phase3-plan.md +++ b/docs/jit-executor-phase3-plan.md @@ -573,16 +573,32 @@ decoded live XPC dump. 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: IN PROGRESS - -P3.1 + P3.2 done. The agent links a real SwiftUI preview, runs its body and -renders it to a bitmap on the agent's main thread (P3.1a/b-i/b-ii), over a -contiguous **anonymous** executor-memory slab (P3.1b-iii) that fixes U-A while -staying within macOS's executable-memory rules (the shared-memory slab was -reverted as macOS-incompatible). P3.2 proves recompile → respawn → re-render -through the real `Compiler` (default target works, no `compileObject` change). -Suite 29/29, 10/10 parallel runs green, zero crashes. Next: P3.4 (daemon -session-lifecycle integration; P3.3 stays conditional). +## 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 @@ -594,18 +610,41 @@ session-lifecycle integration; P3.3 stays conditional). ## Immediate next step (resume pointer for a fresh session) -**State:** P3.1 + P3.2 are DONE on `main`-bound PR #190, CI green. Branch -`jit-phase3-session-integration` (baseline `a8d5909` = anonymous contiguous -mapper; P3.2 adds only a test). Working tree clean. Run JIT tests with -`swift test --filter PreviewsJITLinkTests` (builds `PreviewAgent` too); expect -29 tests green, zero orphan `PreviewAgent` processes. If `third_party/llvm-build` -is missing, run the bootstrap skill with `--jit` (do NOT rebuild LLVM if it -exists). - -**Next:** P3.4 (daemon session-lifecycle integration, pulls in SP5) — route -structural edits from `PreviewSession`/FileWatcher to the JIT respawn path while -literal-only edits stay on `DesignTimeStore`. P3.3 (Begin/End/cancelUpdate -handshake) stays conditional. The respawn mechanism itself is proven by P3.2. +**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 @@ -619,3 +658,7 @@ handshake) stays conditional. The respawn mechanism itself is proven by P3.2. - 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. From 34613afbc97d1f09506060e9a9d3e99fa9641bc2 Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Thu, 4 Jun 2026 11:45:43 -0400 Subject: [PATCH 30/46] JIT executor Phase 4 (P4.1-a): Compiler split primitive for recompile-narrowing (#191) Add Compiler.emitStableModule: compile the bulk of a module once into a whole-module .o plus a -enable-testing .swiftmodule. The editable unit then compiles a single file against it (-I modulesDir, @testable import), so an edit never re-parses the bulk. SplitCompileTests proves the mechanism at the unit layer: the editable unit consumes a stable-module symbol cross-module, JIT-links stable.o + editable.o, and renders correct pixels; the stable module is reused across edits; and the per-edit compile stays flat while the whole-module baseline grows with size (N=24: editable-only 180ms vs whole-module 312ms), reproducing the W5/W7 lever. This is the Compiler half only. Wiring PreviewSession.compileObjectForJIT() and JITRenderBuild to carry the two objects is the next step. Co-Authored-By: Claude Opus 4.8 --- Sources/PreviewsCore/Compiler.swift | 54 ++++++ .../SplitCompileTests.swift | 170 ++++++++++++++++++ 2 files changed, 224 insertions(+) create mode 100644 Tests/PreviewsJITLinkTests/SplitCompileTests.swift diff --git a/Sources/PreviewsCore/Compiler.swift b/Sources/PreviewsCore/Compiler.swift index 6ec9808..6a8ee41 100644 --- a/Sources/PreviewsCore/Compiler.swift +++ b/Sources/PreviewsCore/Compiler.swift @@ -178,6 +178,60 @@ 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) + } + + 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/Tests/PreviewsJITLinkTests/SplitCompileTests.swift b/Tests/PreviewsJITLinkTests/SplitCompileTests.swift new file mode 100644 index 0000000..acabbf1 --- /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 * 0.8) + } +} From 58893fe1d01b9b595948dcbeacf10ef1a2f709d1 Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Thu, 4 Jun 2026 12:01:32 -0400 Subject: [PATCH 31/46] JIT executor Phase 4 (P4.1-b): wire compileObjectForJIT to the Tier-2 split (#191) In Tier-2 project mode, compileObjectForJIT() now narrows the recompile to the hot preview file. The bulk (ctx.sourceFiles, which the build systems already exclude the preview file from) is built once into a -enable-testing stable module; the hot file compiles as a separate module PreviewEdit_ that @testable imports it (-I stable.modulesDir). Standalone mode is unchanged. JITRenderBuild gains supportObjectPaths (the stable .o, linked before the editable object); empty for standalone so the live daemon path is untouched. generateCombinedSource gains stableModuleImport to emit the @testable import. Compiler gains a file-based emitStableModule(sourceFiles:) that compiles the project sources in place. PreviewSessionSplitTests: the hot preview file consumes a bulk Palette symbol cross-module, renders red; a structural edit re-renders blue. Proves the session-level split end to end. Reloader/JITRenderBuild plumbing (linking both objects in the daemon) is B3. Co-Authored-By: Claude Opus 4.8 --- Sources/PreviewsCore/BridgeGenerator.swift | 7 +- Sources/PreviewsCore/Compiler.swift | 27 +++++ Sources/PreviewsCore/PreviewSession.swift | 61 ++++++++-- .../PreviewSessionSplitTests.swift | 111 ++++++++++++++++++ docs/jit-executor-phase3-plan.md | 58 +++++++++ 5 files changed, 255 insertions(+), 9 deletions(-) create mode 100644 Tests/PreviewsJITLinkTests/PreviewSessionSplitTests.swift diff --git a/Sources/PreviewsCore/BridgeGenerator.swift b/Sources/PreviewsCore/BridgeGenerator.swift index e33a7e9..0cd7ad3 100644 --- a/Sources/PreviewsCore/BridgeGenerator.swift +++ b/Sources/PreviewsCore/BridgeGenerator.swift @@ -23,7 +23,8 @@ public enum BridgeGenerator { setupModule: String? = nil, setupType: String? = nil, renderOutputPath: String? = nil, - designTimeValuesPath: 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) @@ -94,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) --- diff --git a/Sources/PreviewsCore/Compiler.swift b/Sources/PreviewsCore/Compiler.swift index 6a8ee41..04105ea 100644 --- a/Sources/PreviewsCore/Compiler.swift +++ b/Sources/PreviewsCore/Compiler.swift @@ -207,6 +207,33 @@ public actor Compiler { 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") diff --git a/Sources/PreviewsCore/PreviewSession.swift b/Sources/PreviewsCore/PreviewSession.swift index b400006..6e9c830 100644 --- a/Sources/PreviewsCore/PreviewSession.swift +++ b/Sources/PreviewsCore/PreviewSession.swift @@ -17,6 +17,25 @@ public struct JITRenderBuild: Sendable { 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] + + public init( + objectPath: URL, + imagePath: URL, + valuesPath: URL, + entrySymbol: String, + literals: [LiteralEntry], + supportObjectPaths: [URL] = [] + ) { + self.objectPath = objectPath + self.imagePath = imagePath + self.valuesPath = valuesPath + self.entrySymbol = entrySymbol + self.literals = literals + self.supportObjectPaths = supportObjectPaths + } } /// Orchestrates the full preview pipeline: parse → generate bridge → compile → return dylib path. @@ -163,7 +182,12 @@ 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. Standalone (combined-source) mode only for now. + /// 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) @@ -182,6 +206,11 @@ public actor PreviewSession { 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, @@ -189,13 +218,30 @@ public actor PreviewSession { platform: platform, traits: traits, renderOutputPath: imagePath.path, - designTimeValuesPath: valuesPath.path + designTimeValuesPath: valuesPath.path, + stableModuleImport: splitContext?.0.moduleName ) - let objectPath = try await compiler.compileObject( - source: generated.source, - moduleName: Self.moduleName(for: sourceFile) - ) + let objectPath: URL + var supportObjectPaths: [URL] = [] + if let (ctx, bulk) = splitContext { + let stable = try await compiler.emitStableModule( + sourceFiles: bulk, + moduleName: ctx.moduleName, + extraFlags: ctx.compilerFlags + ) + supportObjectPaths = [stable.objectPath] + objectPath = try await compiler.compileObject( + source: generated.source, + moduleName: "PreviewEdit_\(ctx.moduleName)", + extraFlags: ["-I", stable.modulesDir.path] + ctx.compilerFlags + ) + } else { + objectPath = try await compiler.compileObject( + source: generated.source, + moduleName: Self.moduleName(for: sourceFile) + ) + } try Self.writeDesignTimeValues(generated.literals, to: valuesPath) lastOriginalSource = source @@ -206,7 +252,8 @@ public actor PreviewSession { imagePath: imagePath, valuesPath: valuesPath, entrySymbol: "renderPreviewToFile", - literals: generated.literals + literals: generated.literals, + supportObjectPaths: supportObjectPaths ) lastJITBuild = build return build diff --git a/Tests/PreviewsJITLinkTests/PreviewSessionSplitTests.swift b/Tests/PreviewsJITLinkTests/PreviewSessionSplitTests.swift new file mode 100644 index 0000000..31970f6 --- /dev/null +++ b/Tests/PreviewsJITLinkTests/PreviewSessionSplitTests.swift @@ -0,0 +1,111 @@ +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 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/docs/jit-executor-phase3-plan.md b/docs/jit-executor-phase3-plan.md index 0c93697..8fe3a20 100644 --- a/docs/jit-executor-phase3-plan.md +++ b/docs/jit-executor-phase3-plan.md @@ -662,3 +662,61 @@ P3.3 (in-place `write_mem` + Begin/End/cancelUpdate handshake) stays conditional `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** — cache the stable module across edits; rebuild only when `hotFile` + identity changes. Verify: same-file edit does NOT re-emit the stable module; + different-file edit does. +- **B3** — carry both object paths through `JITRenderBuild` + `JITStructuralReloader` + so the daemon renders the split (reloader `addObject` stable then editable). + Verify: `PreviewHostJITReloadTests`-style structural reload renders via two objects. +- **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). From 82f9898eab989cca1f3acbd252ad86344ad23472 Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Thu, 4 Jun 2026 12:03:44 -0400 Subject: [PATCH 32/46] JIT executor Phase 4 (P4.1-c): cache the stable module across hot-file edits (#191) PreviewSession now caches the prebuilt stable module, keyed by the bulk files' modification dates. Repeated edits to the hot preview file reuse it, so the per-edit compile recompiles only the hot file (the flat fast path). A change to any bulk file invalidates the cache and rebuilds. Within a session the hot file is fixed (self.sourceFile), so the changed-hot-file case is a different session. PreviewSessionSplitTests: two hot-file edits share supportObjectPaths (cached); touching a bulk file produces a fresh stable object. Co-Authored-By: Claude Opus 4.8 --- Sources/PreviewsCore/PreviewSession.swift | 37 ++++++++++++++--- .../PreviewSessionSplitTests.swift | 41 +++++++++++++++++++ docs/jit-executor-phase3-plan.md | 10 +++-- 3 files changed, 80 insertions(+), 8 deletions(-) diff --git a/Sources/PreviewsCore/PreviewSession.swift b/Sources/PreviewsCore/PreviewSession.swift index 6e9c830..0bb875f 100644 --- a/Sources/PreviewsCore/PreviewSession.swift +++ b/Sources/PreviewsCore/PreviewSession.swift @@ -56,6 +56,7 @@ public actor PreviewSession { 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 @@ -225,11 +226,7 @@ public actor PreviewSession { let objectPath: URL var supportObjectPaths: [URL] = [] if let (ctx, bulk) = splitContext { - let stable = try await compiler.emitStableModule( - sourceFiles: bulk, - moduleName: ctx.moduleName, - extraFlags: ctx.compilerFlags - ) + let stable = try await stableModule(for: bulk, context: ctx) supportObjectPaths = [stable.objectPath] objectPath = try await compiler.compileObject( source: generated.source, @@ -259,6 +256,36 @@ public actor PreviewSession { return build } + /// 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 diff --git a/Tests/PreviewsJITLinkTests/PreviewSessionSplitTests.swift b/Tests/PreviewsJITLinkTests/PreviewSessionSplitTests.swift index 31970f6..f1f3494 100644 --- a/Tests/PreviewsJITLinkTests/PreviewSessionSplitTests.swift +++ b/Tests/PreviewsJITLinkTests/PreviewSessionSplitTests.swift @@ -83,6 +83,47 @@ struct PreviewSessionSplitTests { #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 structuralEditReRendersThroughSplit() async throws { let project = try Self.makeProject( previewBody: "Palette.square(red: 1, green: 0, blue: 0)") diff --git a/docs/jit-executor-phase3-plan.md b/docs/jit-executor-phase3-plan.md index 8fe3a20..7d532c2 100644 --- a/docs/jit-executor-phase3-plan.md +++ b/docs/jit-executor-phase3-plan.md @@ -701,9 +701,13 @@ reuse across edits, flat 180ms vs 312ms whole at N=24). The editable unit reuses `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** — cache the stable module across edits; rebuild only when `hotFile` - identity changes. Verify: same-file edit does NOT re-emit the stable module; - different-file edit does. +- **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** — carry both object paths through `JITRenderBuild` + `JITStructuralReloader` so the daemon renders the split (reloader `addObject` stable then editable). Verify: `PreviewHostJITReloadTests`-style structural reload renders via two objects. From 37f29e322199697b47ea4789f89b3b8cb30619fa Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Thu, 4 Jun 2026 12:07:22 -0400 Subject: [PATCH 33/46] JIT executor Phase 4 (P4.1-d): link the split's stable objects in the reloader (#191) StructuralReloader.renderObject gains supportObjectPaths: the JIT reloader adds those prebuilt stable-module objects before the editable object, so the agent links stable.o then editable.o and resolves the editable unit's @testable references. The daemon callers (jitStructuralReload/jitLiteralReload) pass build.supportObjectPaths; standalone passes [] so its path is unchanged. reloaderRendersSplitBuildThroughBothObjects renders a project-mode split build red through the real reloader. Test mocks and direct callers updated for the new signature. This completes the P4.1 split end to end through the daemon seam: a Tier-2 structural edit recompiles only the hot file and renders via stable+editable objects. Capped-persistent reloader, examples E2E, and the FileWatcher path (G2) remain. Co-Authored-By: Claude Opus 4.8 --- Sources/PreviewsCore/StructuralReloader.swift | 6 ++++- .../JITStructuralReloader.swift | 7 ++++- Sources/PreviewsMacOS/HostApp.swift | 8 ++++-- .../StructuralReloaderTests.swift | 8 ++++-- ...MacOSPreviewHandleAgentSnapshotTests.swift | 4 ++- .../JITStructuralReloaderTests.swift | 12 ++++++--- .../PreviewSessionSplitTests.swift | 27 +++++++++++++++++++ .../StructuralReloadLatencyTests.swift | 8 ++++-- .../PreviewHostJITReloadTests.swift | 4 ++- docs/jit-executor-phase3-plan.md | 10 ++++--- 10 files changed, 78 insertions(+), 16 deletions(-) diff --git a/Sources/PreviewsCore/StructuralReloader.swift b/Sources/PreviewsCore/StructuralReloader.swift index a9fba8f..6d62fc5 100644 --- a/Sources/PreviewsCore/StructuralReloader.swift +++ b/Sources/PreviewsCore/StructuralReloader.swift @@ -10,5 +10,9 @@ import Foundation /// 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 { - func renderObject(at objectPath: URL, entrySymbol: String) async throws + /// Render `objectPath`'s entry, first linking any `supportObjectPaths` (the prebuilt + /// stable-module objects from the recompile-narrowing split). `supportObjectPaths` is + /// empty for the standalone path, where `objectPath` is self-contained. + func renderObject(at objectPath: URL, supportObjectPaths: [URL], entrySymbol: String) + async throws } diff --git a/Sources/PreviewsJITLink/JITStructuralReloader.swift b/Sources/PreviewsJITLink/JITStructuralReloader.swift index 896517b..ca65d9f 100644 --- a/Sources/PreviewsJITLink/JITStructuralReloader.swift +++ b/Sources/PreviewsJITLink/JITStructuralReloader.swift @@ -9,8 +9,13 @@ import PreviewsCore public struct JITStructuralReloader: StructuralReloader { public init() {} - public func renderObject(at objectPath: URL, entrySymbol: String) async throws { + public func renderObject(at objectPath: URL, supportObjectPaths: [URL], entrySymbol: String) + async throws + { let session = try JITSession(remoteAgentPath: JITSession.bundledAgentPath()) + for support in supportObjectPaths { + try session.addObject(path: support.path) + } try session.addObject(path: objectPath.path) let status = try session.runOnMain(symbol: entrySymbol) guard status == 0 else { diff --git a/Sources/PreviewsMacOS/HostApp.swift b/Sources/PreviewsMacOS/HostApp.swift index 444a0a6..d334d7d 100644 --- a/Sources/PreviewsMacOS/HostApp.swift +++ b/Sources/PreviewsMacOS/HostApp.swift @@ -356,7 +356,9 @@ public class PreviewHost: NSObject, NSApplicationDelegate { 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.renderObject(at: build.objectPath, entrySymbol: build.entrySymbol) + try await reloader.renderObject( + at: build.objectPath, supportObjectPaths: build.supportObjectPaths, + entrySymbol: build.entrySymbol) agentImagePaths[sessionID] = build.imagePath return build.imagePath } @@ -379,7 +381,9 @@ public class PreviewHost: NSObject, NSApplicationDelegate { let reloader = structuralReloader, let build = try await session.applyLiteralValuesForJIT(changes) else { return nil } - try await reloader.renderObject(at: build.objectPath, entrySymbol: build.entrySymbol) + try await reloader.renderObject( + at: build.objectPath, supportObjectPaths: build.supportObjectPaths, + entrySymbol: build.entrySymbol) agentImagePaths[sessionID] = build.imagePath return build.imagePath } diff --git a/Tests/PreviewsCoreTests/StructuralReloaderTests.swift b/Tests/PreviewsCoreTests/StructuralReloaderTests.swift index 3e67bda..838ac96 100644 --- a/Tests/PreviewsCoreTests/StructuralReloaderTests.swift +++ b/Tests/PreviewsCoreTests/StructuralReloaderTests.swift @@ -8,7 +8,9 @@ struct StructuralReloaderTests { private actor MockReloader: StructuralReloader { private(set) var calls: [(objectPath: URL, entrySymbol: String)] = [] - func renderObject(at objectPath: URL, entrySymbol: String) async throws { + func renderObject(at objectPath: URL, supportObjectPaths: [URL], entrySymbol: String) + async throws + { calls.append((objectPath: objectPath, entrySymbol: entrySymbol)) } func recorded() -> [(objectPath: URL, entrySymbol: String)] { calls } @@ -47,7 +49,9 @@ struct StructuralReloaderTests { #expect(symbols.contains("_renderPreviewToFile")) let reloader = MockReloader() - try await reloader.renderObject(at: build.objectPath, entrySymbol: build.entrySymbol) + try await reloader.renderObject( + at: build.objectPath, supportObjectPaths: build.supportObjectPaths, + entrySymbol: build.entrySymbol) let calls = await reloader.recorded() #expect(calls.count == 1) #expect(calls.first?.objectPath == build.objectPath) diff --git a/Tests/PreviewsEngineTests/MacOSPreviewHandleAgentSnapshotTests.swift b/Tests/PreviewsEngineTests/MacOSPreviewHandleAgentSnapshotTests.swift index 4f2fcbb..5dd42f4 100644 --- a/Tests/PreviewsEngineTests/MacOSPreviewHandleAgentSnapshotTests.swift +++ b/Tests/PreviewsEngineTests/MacOSPreviewHandleAgentSnapshotTests.swift @@ -11,7 +11,9 @@ import Testing struct MacOSPreviewHandleAgentSnapshotTests { final class RecordingReloader: StructuralReloader, @unchecked Sendable { - func renderObject(at objectPath: URL, entrySymbol: String) async throws {} + func renderObject(at objectPath: URL, supportObjectPaths: [URL], entrySymbol: String) + async throws + {} } @Test func snapshotReturnsAgentImage() async throws { diff --git a/Tests/PreviewsJITLinkTests/JITStructuralReloaderTests.swift b/Tests/PreviewsJITLinkTests/JITStructuralReloaderTests.swift index 0619de1..2fc38a7 100644 --- a/Tests/PreviewsJITLinkTests/JITStructuralReloaderTests.swift +++ b/Tests/PreviewsJITLinkTests/JITStructuralReloaderTests.swift @@ -31,7 +31,9 @@ struct JITStructuralReloaderTests { let build = try await session.compileObjectForJIT() let reloader = JITStructuralReloader() - try await reloader.renderObject(at: build.objectPath, entrySymbol: build.entrySymbol) + try await reloader.renderObject( + at: build.objectPath, supportObjectPaths: build.supportObjectPaths, + entrySymbol: build.entrySymbol) let data = try Data(contentsOf: build.imagePath) #expect(!data.isEmpty) @@ -71,7 +73,9 @@ struct JITStructuralReloaderTests { let build = try await session.compileObjectForJIT() let reloader = JITStructuralReloader() - try await reloader.renderObject(at: build.objectPath, entrySymbol: build.entrySymbol) + try await reloader.renderObject( + at: build.objectPath, supportObjectPaths: build.supportObjectPaths, + entrySymbol: build.entrySymbol) let b1 = try Self.centerBrightness(build.imagePath) #expect(b1 < 0.4) @@ -84,7 +88,9 @@ struct JITStructuralReloaderTests { values[whiteLiteral.id] = 0.9 try JSONSerialization.data(withJSONObject: values).write(to: build.valuesPath) - try await reloader.renderObject(at: build.objectPath, entrySymbol: build.entrySymbol) + try await reloader.renderObject( + at: build.objectPath, supportObjectPaths: build.supportObjectPaths, + entrySymbol: build.entrySymbol) let b2 = try Self.centerBrightness(build.imagePath) #expect(b2 > 0.7) #expect(b2 > b1) diff --git a/Tests/PreviewsJITLinkTests/PreviewSessionSplitTests.swift b/Tests/PreviewsJITLinkTests/PreviewSessionSplitTests.swift index f1f3494..07687f0 100644 --- a/Tests/PreviewsJITLinkTests/PreviewSessionSplitTests.swift +++ b/Tests/PreviewsJITLinkTests/PreviewSessionSplitTests.swift @@ -124,6 +124,33 @@ struct PreviewSessionSplitTests { #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.renderObject( + at: build.objectPath, + supportObjectPaths: build.supportObjectPaths, + entrySymbol: build.entrySymbol + ) + + 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)") diff --git a/Tests/PreviewsJITLinkTests/StructuralReloadLatencyTests.swift b/Tests/PreviewsJITLinkTests/StructuralReloadLatencyTests.swift index 04b9921..352c140 100644 --- a/Tests/PreviewsJITLinkTests/StructuralReloadLatencyTests.swift +++ b/Tests/PreviewsJITLinkTests/StructuralReloadLatencyTests.swift @@ -36,13 +36,17 @@ struct StructuralReloadLatencyTests { // Warm up: first compile pays module-cache warmup, first agent pays spawn cost. let warm = try await session.compileObjectForJIT() - try await reloader.renderObject(at: warm.objectPath, entrySymbol: warm.entrySymbol) + try await reloader.renderObject( + at: warm.objectPath, supportObjectPaths: warm.supportObjectPaths, + entrySymbol: warm.entrySymbol) let clock = ContinuousClock() let t0 = clock.now let build = try await session.compileObjectForJIT() let t1 = clock.now - try await reloader.renderObject(at: build.objectPath, entrySymbol: build.entrySymbol) + try await reloader.renderObject( + at: build.objectPath, supportObjectPaths: build.supportObjectPaths, + entrySymbol: build.entrySymbol) let t2 = clock.now let compileMs = Self.ms(t0.duration(to: t1)) diff --git a/Tests/PreviewsMacOSTests/PreviewHostJITReloadTests.swift b/Tests/PreviewsMacOSTests/PreviewHostJITReloadTests.swift index 0fa603d..f290b13 100644 --- a/Tests/PreviewsMacOSTests/PreviewHostJITReloadTests.swift +++ b/Tests/PreviewsMacOSTests/PreviewHostJITReloadTests.swift @@ -10,7 +10,9 @@ struct PreviewHostJITReloadTests { final class RecordingReloader: StructuralReloader, @unchecked Sendable { private(set) var calls: [(objectPath: URL, entrySymbol: String)] = [] - func renderObject(at objectPath: URL, entrySymbol: String) async throws { + func renderObject(at objectPath: URL, supportObjectPaths: [URL], entrySymbol: String) + async throws + { calls.append((objectPath: objectPath, entrySymbol: entrySymbol)) } } diff --git a/docs/jit-executor-phase3-plan.md b/docs/jit-executor-phase3-plan.md index 7d532c2..781ebf6 100644 --- a/docs/jit-executor-phase3-plan.md +++ b/docs/jit-executor-phase3-plan.md @@ -708,9 +708,13 @@ reuse across edits, flat 180ms vs 312ms whole at N=24). The editable unit reuses "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** — carry both object paths through `JITRenderBuild` + `JITStructuralReloader` - so the daemon renders the split (reloader `addObject` stable then editable). - Verify: `PreviewHostJITReloadTests`-style structural reload renders via two objects. +- **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. - **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. From 5d6e48c41d6fc0c2e524dbf3c77c968aa976c84c Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Thu, 4 Jun 2026 12:29:51 -0400 Subject: [PATCH 34/46] JIT executor Phase 4 (P4.1-e): examples E2E for the split; surfaces dep-loading gap (#191) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ExamplesSplitE2ETests drives a real SPMBuildSystem BuildContext for examples/spm (ToDo target). The split COMPILES a real cross-file preview (Summary.swift: same-module Item + sibling ToDoExtras package-scoped decls) — the real -I/-package-name/@testable flags flow correctly and both objects emit; the literal path classifies a real preview and reuses the same object. Surfaced G3: the JIT agent does not load the target's dependency archives (libToDoExtras.a / libLocalDep.a), so their symbols are unresolved at JIT-link time. The non-JIT Tier-2 path links a dylib with -L/-l so dyld resolves them; the JIT path only links our stable.o + editable.o. This is a prerequisite for JIT Tier-2 on any real multi-target project, orthogonal to recompile-narrowing. The in-agent render test is .disabled pending G3; the fix is to add the -L/-l archives as StaticLibraryDefinitionGenerators (+ dlopen binary dylib deps). Captured in docs/jit-executor-phase3-plan.md. Co-Authored-By: Claude Opus 4.8 --- .../ExamplesSplitE2ETests.swift | 89 +++++++++++++++++++ docs/jit-executor-phase3-plan.md | 22 +++++ 2 files changed, 111 insertions(+) create mode 100644 Tests/PreviewsJITLinkTests/ExamplesSplitE2ETests.swift diff --git a/Tests/PreviewsJITLinkTests/ExamplesSplitE2ETests.swift b/Tests/PreviewsJITLinkTests/ExamplesSplitE2ETests.swift new file mode 100644 index 0000000..436806d --- /dev/null +++ b/Tests/PreviewsJITLinkTests/ExamplesSplitE2ETests.swift @@ -0,0 +1,89 @@ +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()`. +/// +/// The split COMPILE is validated here. Rendering a real Tier-2 preview in the agent is +/// blocked on G3 (the JIT agent does not yet load the target's dependency archives +/// `libToDoExtras.a` / `libLocalDep.a` — their symbols are unresolved at JIT-link time); +/// the render test below is disabled until G3 lands. 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( + .disabled( + "G3: JIT agent does not yet load the target's dependency archives (libToDoExtras.a / libLocalDep.a); their symbols are unresolved at JIT-link time. Enable once G3 lands." + )) + 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.renderObject( + at: build.objectPath, + supportObjectPaths: build.supportObjectPaths, + entrySymbol: build.entrySymbol + ) + let png = try Data(contentsOf: build.imagePath) + #expect(!png.isEmpty) + #expect(NSBitmapImageRep(data: png) != nil) + } +} diff --git a/docs/jit-executor-phase3-plan.md b/docs/jit-executor-phase3-plan.md index 781ebf6..b208690 100644 --- a/docs/jit-executor-phase3-plan.md +++ b/docs/jit-executor-phase3-plan.md @@ -715,6 +715,28 @@ reuse across edits, flat 180ms vs 312ms whole at N=24). The editable unit reuses `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 Tier-2 dependency archives (DISCOVERED by the E2E).** + The non-JIT Tier-2 path links a dylib with `-L -l` so dyld resolves + sibling/cross-package targets; the JIT path emits `.o`s and the agent JIT-links only + our stable.o + editable.o, so dependency symbols (`ToDoExtras.ProgressFormatter`, + `LocalDep.Badge`, …) are **unresolved at JIT-link time** (materialization fails; + the agent then `SIGABRT`s on a dangling `SymbolStringPool` — a secondary teardown + robustness bug). `SPMBuildSystem` archives those deps into `lib.a` under + `binPath`. Fix: add the `-L`/`-l` archives to the JIT session as + `StaticLibraryDefinitionGenerator`s (lazy archive linking pulls in the needed dep + objects); a binary dylib/framework dep (e.g. Lottie) needs `dlopen` in the agent so + the process-symbol generator resolves it. This is a **prerequisite for JIT Tier-2 + on any real multi-target project**, orthogonal to recompile-narrowing, and gates + the render half of item 3. Verify: enabling `splitRendersRealPreviewInAgent` + renders `Summary.swift` to a non-empty PNG. - **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. From dbdf999c2dc9ecf39130edbc1d9048a824a5c070 Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Thu, 4 Jun 2026 12:52:32 -0400 Subject: [PATCH 35/46] JIT executor Phase 4 (P4.1-f / G3-a): load static dependency archives into the JIT agent (#191) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The examples E2E showed real Tier-2 previews fail to render because the agent JIT-links only our stable.o + editable.o, leaving the target's dependency symbols unresolved. G3-a handles the static-archive layer: SPMBuildSystem archives sibling/cross-package targets into lib.a and passes -L/-l. - C API previewsmcp_jit_session_add_archive → StaticLibraryDefinitionGenerator::Load on the session JITDylib (lazy archive linking pulls in only the referenced dep objects). - Swift JITSession.addArchive; JITRenderBuild.archivePaths, parsed from ctx.compilerFlags (-L/-l) by PreviewSession.dependencyArchives. - StructuralReloader.renderObject gains archivePaths; the JIT reloader adds the archives before the objects. Standalone passes [] (unchanged). ArchiveLoadingTests proves a JIT-linked object resolves a symbol from a libtool static archive in the agent. The real E2E now resolves ToDoExtras/LocalDep. Render of a full real preview is still blocked (the stable module bundles every target file, so a dep-free hot file still pulls the whole target's deps): G3-b (dlopen binary frameworks, e.g. Lottie, via EPCDynamicLibrarySearchGenerator) and G3-c (the compiler-rt builtin ___isPlatformVersionAtLeast from #available). splitRendersRealPreviewInAgent stays .disabled with those precise blockers; captured in docs/jit-executor-phase3-plan.md. Co-Authored-By: Claude Opus 4.8 --- Sources/PreviewsCore/PreviewSession.swift | 44 ++++++++++++++++- Sources/PreviewsCore/StructuralReloader.swift | 12 +++-- .../JITStructuralReloader.swift | 9 ++-- Sources/PreviewsJITLink/PreviewsJITLink.swift | 6 +++ .../PreviewsJITLinkCxx/PreviewsJITLinkCxx.cpp | 13 +++++ .../include/PreviewsJITLinkCxx.h | 3 ++ Sources/PreviewsMacOS/HostApp.swift | 4 +- .../StructuralReloaderTests.swift | 8 +-- ...MacOSPreviewHandleAgentSnapshotTests.swift | 6 +-- .../ArchiveLoadingTests.swift | 49 +++++++++++++++++++ .../ExamplesSplitE2ETests.swift | 13 +++-- .../JITStructuralReloaderTests.swift | 6 +-- .../PreviewSessionSplitTests.swift | 1 + .../StructuralReloadLatencyTests.swift | 4 +- .../PreviewHostJITReloadTests.swift | 6 +-- docs/jit-executor-phase3-plan.md | 41 +++++++++++----- 16 files changed, 180 insertions(+), 45 deletions(-) create mode 100644 Tests/PreviewsJITLinkTests/ArchiveLoadingTests.swift diff --git a/Sources/PreviewsCore/PreviewSession.swift b/Sources/PreviewsCore/PreviewSession.swift index 0bb875f..a2a138f 100644 --- a/Sources/PreviewsCore/PreviewSession.swift +++ b/Sources/PreviewsCore/PreviewSession.swift @@ -20,6 +20,9 @@ public struct JITRenderBuild: Sendable { /// 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] public init( objectPath: URL, @@ -27,7 +30,8 @@ public struct JITRenderBuild: Sendable { valuesPath: URL, entrySymbol: String, literals: [LiteralEntry], - supportObjectPaths: [URL] = [] + supportObjectPaths: [URL] = [], + archivePaths: [URL] = [] ) { self.objectPath = objectPath self.imagePath = imagePath @@ -35,6 +39,7 @@ public struct JITRenderBuild: Sendable { self.entrySymbol = entrySymbol self.literals = literals self.supportObjectPaths = supportObjectPaths + self.archivePaths = archivePaths } } @@ -225,9 +230,11 @@ public actor PreviewSession { let objectPath: URL var supportObjectPaths: [URL] = [] + var archivePaths: [URL] = [] if let (ctx, bulk) = splitContext { let stable = try await stableModule(for: bulk, context: ctx) supportObjectPaths = [stable.objectPath] + archivePaths = Self.dependencyArchives(in: ctx.compilerFlags) objectPath = try await compiler.compileObject( source: generated.source, moduleName: "PreviewEdit_\(ctx.moduleName)", @@ -250,12 +257,45 @@ public actor PreviewSession { valuesPath: valuesPath, entrySymbol: "renderPreviewToFile", literals: generated.literals, - supportObjectPaths: supportObjectPaths + supportObjectPaths: supportObjectPaths, + archivePaths: archivePaths ) 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 + } + /// 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. diff --git a/Sources/PreviewsCore/StructuralReloader.swift b/Sources/PreviewsCore/StructuralReloader.swift index 6d62fc5..7346b5f 100644 --- a/Sources/PreviewsCore/StructuralReloader.swift +++ b/Sources/PreviewsCore/StructuralReloader.swift @@ -10,9 +10,11 @@ import Foundation /// 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 `objectPath`'s entry, first linking any `supportObjectPaths` (the prebuilt - /// stable-module objects from the recompile-narrowing split). `supportObjectPaths` is - /// empty for the standalone path, where `objectPath` is self-contained. - func renderObject(at objectPath: URL, supportObjectPaths: [URL], entrySymbol: String) - async throws + /// Render `objectPath`'s entry, first linking any `archivePaths` (the target's + /// dependency archives) and `supportObjectPaths` (the prebuilt stable-module objects + /// from the recompile-narrowing split). Both are empty for the standalone path, where + /// `objectPath` is self-contained. + func renderObject( + at objectPath: URL, supportObjectPaths: [URL], archivePaths: [URL], entrySymbol: String + ) async throws } diff --git a/Sources/PreviewsJITLink/JITStructuralReloader.swift b/Sources/PreviewsJITLink/JITStructuralReloader.swift index ca65d9f..e0e78f7 100644 --- a/Sources/PreviewsJITLink/JITStructuralReloader.swift +++ b/Sources/PreviewsJITLink/JITStructuralReloader.swift @@ -9,10 +9,13 @@ import PreviewsCore public struct JITStructuralReloader: StructuralReloader { public init() {} - public func renderObject(at objectPath: URL, supportObjectPaths: [URL], entrySymbol: String) - async throws - { + public func renderObject( + at objectPath: URL, supportObjectPaths: [URL], archivePaths: [URL], entrySymbol: String + ) async throws { let session = try JITSession(remoteAgentPath: JITSession.bundledAgentPath()) + for archive in archivePaths { + try session.addArchive(path: archive.path) + } for support in supportObjectPaths { try session.addObject(path: support.path) } diff --git a/Sources/PreviewsJITLink/PreviewsJITLink.swift b/Sources/PreviewsJITLink/PreviewsJITLink.swift index 97b6b30..f3ef1e0 100644 --- a/Sources/PreviewsJITLink/PreviewsJITLink.swift +++ b/Sources/PreviewsJITLink/PreviewsJITLink.swift @@ -77,6 +77,12 @@ 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 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 0d37467..a013842 100644 --- a/Sources/PreviewsJITLinkCxx/PreviewsJITLinkCxx.cpp +++ b/Sources/PreviewsJITLinkCxx/PreviewsJITLinkCxx.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -499,6 +500,18 @@ 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_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 76dbce9..43e2698 100644 --- a/Sources/PreviewsJITLinkCxx/include/PreviewsJITLinkCxx.h +++ b/Sources/PreviewsJITLinkCxx/include/PreviewsJITLinkCxx.h @@ -39,6 +39,9 @@ const char *_Nullable previewsmcp_jit_session_write_pointer( 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_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 d334d7d..8063a3f 100644 --- a/Sources/PreviewsMacOS/HostApp.swift +++ b/Sources/PreviewsMacOS/HostApp.swift @@ -358,7 +358,7 @@ public class PreviewHost: NSObject, NSApplicationDelegate { let build = try await session.compileObjectForJIT() try await reloader.renderObject( at: build.objectPath, supportObjectPaths: build.supportObjectPaths, - entrySymbol: build.entrySymbol) + archivePaths: build.archivePaths, entrySymbol: build.entrySymbol) agentImagePaths[sessionID] = build.imagePath return build.imagePath } @@ -383,7 +383,7 @@ public class PreviewHost: NSObject, NSApplicationDelegate { else { return nil } try await reloader.renderObject( at: build.objectPath, supportObjectPaths: build.supportObjectPaths, - entrySymbol: build.entrySymbol) + archivePaths: build.archivePaths, entrySymbol: build.entrySymbol) agentImagePaths[sessionID] = build.imagePath return build.imagePath } diff --git a/Tests/PreviewsCoreTests/StructuralReloaderTests.swift b/Tests/PreviewsCoreTests/StructuralReloaderTests.swift index 838ac96..de4fef3 100644 --- a/Tests/PreviewsCoreTests/StructuralReloaderTests.swift +++ b/Tests/PreviewsCoreTests/StructuralReloaderTests.swift @@ -8,9 +8,9 @@ struct StructuralReloaderTests { private actor MockReloader: StructuralReloader { private(set) var calls: [(objectPath: URL, entrySymbol: String)] = [] - func renderObject(at objectPath: URL, supportObjectPaths: [URL], entrySymbol: String) - async throws - { + func renderObject( + at objectPath: URL, supportObjectPaths: [URL], archivePaths: [URL], entrySymbol: String + ) async throws { calls.append((objectPath: objectPath, entrySymbol: entrySymbol)) } func recorded() -> [(objectPath: URL, entrySymbol: String)] { calls } @@ -51,7 +51,7 @@ struct StructuralReloaderTests { let reloader = MockReloader() try await reloader.renderObject( at: build.objectPath, supportObjectPaths: build.supportObjectPaths, - entrySymbol: build.entrySymbol) + archivePaths: build.archivePaths, entrySymbol: build.entrySymbol) let calls = await reloader.recorded() #expect(calls.count == 1) #expect(calls.first?.objectPath == build.objectPath) diff --git a/Tests/PreviewsEngineTests/MacOSPreviewHandleAgentSnapshotTests.swift b/Tests/PreviewsEngineTests/MacOSPreviewHandleAgentSnapshotTests.swift index 5dd42f4..a407721 100644 --- a/Tests/PreviewsEngineTests/MacOSPreviewHandleAgentSnapshotTests.swift +++ b/Tests/PreviewsEngineTests/MacOSPreviewHandleAgentSnapshotTests.swift @@ -11,9 +11,9 @@ import Testing struct MacOSPreviewHandleAgentSnapshotTests { final class RecordingReloader: StructuralReloader, @unchecked Sendable { - func renderObject(at objectPath: URL, supportObjectPaths: [URL], entrySymbol: String) - async throws - {} + func renderObject( + at objectPath: URL, supportObjectPaths: [URL], archivePaths: [URL], entrySymbol: String + ) async throws {} } @Test func snapshotReturnsAgentImage() async throws { diff --git a/Tests/PreviewsJITLinkTests/ArchiveLoadingTests.swift b/Tests/PreviewsJITLinkTests/ArchiveLoadingTests.swift new file mode 100644 index 0000000..443cd50 --- /dev/null +++ b/Tests/PreviewsJITLinkTests/ArchiveLoadingTests.swift @@ -0,0 +1,49 @@ +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) + } +} diff --git a/Tests/PreviewsJITLinkTests/ExamplesSplitE2ETests.swift b/Tests/PreviewsJITLinkTests/ExamplesSplitE2ETests.swift index 436806d..f4e0f81 100644 --- a/Tests/PreviewsJITLinkTests/ExamplesSplitE2ETests.swift +++ b/Tests/PreviewsJITLinkTests/ExamplesSplitE2ETests.swift @@ -9,10 +9,12 @@ import Testing /// flags, real multi-file target, sibling + cross-package deps), driven through /// `PreviewSession.compileObjectForJIT()`. /// -/// The split COMPILE is validated here. Rendering a real Tier-2 preview in the agent is -/// blocked on G3 (the JIT agent does not yet load the target's dependency archives -/// `libToDoExtras.a` / `libLocalDep.a` — their symbols are unresolved at JIT-link time); -/// the render test below is disabled until G3 lands. See `docs/jit-executor-phase3-plan.md`. +/// The split COMPILE is validated here, and G3-a (static dependency archives) now lets the +/// agent resolve archived sibling/cross-package symbols (ToDoExtras / LocalDep). Rendering a +/// real Tier-2 preview is still blocked on G3-b (dlopen the target's binary frameworks, e.g. +/// Lottie, in the agent) and G3-c (the compiler-rt builtin `___isPlatformVersionAtLeast` +/// emitted by `#available`); the render test below stays disabled until those land. See +/// `docs/jit-executor-phase3-plan.md`. @Suite(.serialized) struct ExamplesSplitE2ETests { static let repoRoot: URL = URL(fileURLWithPath: #filePath) @@ -67,7 +69,7 @@ struct ExamplesSplitE2ETests { @Test( .disabled( - "G3: JIT agent does not yet load the target's dependency archives (libToDoExtras.a / libLocalDep.a); their symbols are unresolved at JIT-link time. Enable once G3 lands." + "G3-a (static archives) lands here and resolves ToDoExtras/LocalDep. Still blocked: the stable module bundles ToDoView.swift, which uses the Lottie binary framework (needs G3-b: dlopen the framework in the agent) and emits `___isPlatformVersionAtLeast` from #available (needs G3-c: the compiler-rt builtin). Enable once G3-b/G3-c land." )) func splitRendersRealPreviewInAgent() async throws { let hot = Self.spmRoot.appendingPathComponent("Sources/ToDo/Summary.swift") @@ -80,6 +82,7 @@ struct ExamplesSplitE2ETests { try await reloader.renderObject( at: build.objectPath, supportObjectPaths: build.supportObjectPaths, + archivePaths: build.archivePaths, entrySymbol: build.entrySymbol ) let png = try Data(contentsOf: build.imagePath) diff --git a/Tests/PreviewsJITLinkTests/JITStructuralReloaderTests.swift b/Tests/PreviewsJITLinkTests/JITStructuralReloaderTests.swift index 2fc38a7..170e665 100644 --- a/Tests/PreviewsJITLinkTests/JITStructuralReloaderTests.swift +++ b/Tests/PreviewsJITLinkTests/JITStructuralReloaderTests.swift @@ -33,7 +33,7 @@ struct JITStructuralReloaderTests { let reloader = JITStructuralReloader() try await reloader.renderObject( at: build.objectPath, supportObjectPaths: build.supportObjectPaths, - entrySymbol: build.entrySymbol) + archivePaths: build.archivePaths, entrySymbol: build.entrySymbol) let data = try Data(contentsOf: build.imagePath) #expect(!data.isEmpty) @@ -75,7 +75,7 @@ struct JITStructuralReloaderTests { try await reloader.renderObject( at: build.objectPath, supportObjectPaths: build.supportObjectPaths, - entrySymbol: build.entrySymbol) + archivePaths: build.archivePaths, entrySymbol: build.entrySymbol) let b1 = try Self.centerBrightness(build.imagePath) #expect(b1 < 0.4) @@ -90,7 +90,7 @@ struct JITStructuralReloaderTests { try await reloader.renderObject( at: build.objectPath, supportObjectPaths: build.supportObjectPaths, - entrySymbol: build.entrySymbol) + archivePaths: build.archivePaths, entrySymbol: build.entrySymbol) let b2 = try Self.centerBrightness(build.imagePath) #expect(b2 > 0.7) #expect(b2 > b1) diff --git a/Tests/PreviewsJITLinkTests/PreviewSessionSplitTests.swift b/Tests/PreviewsJITLinkTests/PreviewSessionSplitTests.swift index 07687f0..654fa76 100644 --- a/Tests/PreviewsJITLinkTests/PreviewSessionSplitTests.swift +++ b/Tests/PreviewsJITLinkTests/PreviewSessionSplitTests.swift @@ -138,6 +138,7 @@ struct PreviewSessionSplitTests { try await reloader.renderObject( at: build.objectPath, supportObjectPaths: build.supportObjectPaths, + archivePaths: build.archivePaths, entrySymbol: build.entrySymbol ) diff --git a/Tests/PreviewsJITLinkTests/StructuralReloadLatencyTests.swift b/Tests/PreviewsJITLinkTests/StructuralReloadLatencyTests.swift index 352c140..61abc10 100644 --- a/Tests/PreviewsJITLinkTests/StructuralReloadLatencyTests.swift +++ b/Tests/PreviewsJITLinkTests/StructuralReloadLatencyTests.swift @@ -38,7 +38,7 @@ struct StructuralReloadLatencyTests { let warm = try await session.compileObjectForJIT() try await reloader.renderObject( at: warm.objectPath, supportObjectPaths: warm.supportObjectPaths, - entrySymbol: warm.entrySymbol) + archivePaths: warm.archivePaths, entrySymbol: warm.entrySymbol) let clock = ContinuousClock() let t0 = clock.now @@ -46,7 +46,7 @@ struct StructuralReloadLatencyTests { let t1 = clock.now try await reloader.renderObject( at: build.objectPath, supportObjectPaths: build.supportObjectPaths, - entrySymbol: build.entrySymbol) + archivePaths: build.archivePaths, entrySymbol: build.entrySymbol) let t2 = clock.now let compileMs = Self.ms(t0.duration(to: t1)) diff --git a/Tests/PreviewsMacOSTests/PreviewHostJITReloadTests.swift b/Tests/PreviewsMacOSTests/PreviewHostJITReloadTests.swift index f290b13..98d1da5 100644 --- a/Tests/PreviewsMacOSTests/PreviewHostJITReloadTests.swift +++ b/Tests/PreviewsMacOSTests/PreviewHostJITReloadTests.swift @@ -10,9 +10,9 @@ struct PreviewHostJITReloadTests { final class RecordingReloader: StructuralReloader, @unchecked Sendable { private(set) var calls: [(objectPath: URL, entrySymbol: String)] = [] - func renderObject(at objectPath: URL, supportObjectPaths: [URL], entrySymbol: String) - async throws - { + func renderObject( + at objectPath: URL, supportObjectPaths: [URL], archivePaths: [URL], entrySymbol: String + ) async throws { calls.append((objectPath: objectPath, entrySymbol: entrySymbol)) } } diff --git a/docs/jit-executor-phase3-plan.md b/docs/jit-executor-phase3-plan.md index b208690..31f538b 100644 --- a/docs/jit-executor-phase3-plan.md +++ b/docs/jit-executor-phase3-plan.md @@ -723,19 +723,34 @@ reuse across edits, flat 180ms vs 312ms whole at N=24). The editable unit reuses `-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 Tier-2 dependency archives (DISCOVERED by the E2E).** - The non-JIT Tier-2 path links a dylib with `-L -l` so dyld resolves - sibling/cross-package targets; the JIT path emits `.o`s and the agent JIT-links only - our stable.o + editable.o, so dependency symbols (`ToDoExtras.ProgressFormatter`, - `LocalDep.Badge`, …) are **unresolved at JIT-link time** (materialization fails; - the agent then `SIGABRT`s on a dangling `SymbolStringPool` — a secondary teardown - robustness bug). `SPMBuildSystem` archives those deps into `lib.a` under - `binPath`. Fix: add the `-L`/`-l` archives to the JIT session as - `StaticLibraryDefinitionGenerator`s (lazy archive linking pulls in the needed dep - objects); a binary dylib/framework dep (e.g. Lottie) needs `dlopen` in the agent so - the process-symbol generator resolves it. This is a **prerequisite for JIT Tier-2 - on any real multi-target project**, orthogonal to recompile-narrowing, and gates - the render half of item 3. Verify: enabling `splitRendersRealPreviewInAgent` +- **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 (TODO).** `-F/-framework` deps (e.g. `Lottie.framework`) + are binary dylibs. The agent must `dlopen` them, via + `EPCDynamicLibrarySearchGenerator::Load` over EPC (resolve the framework binary at + `/.framework/`). + - **G3-c — compiler-rt builtin (TODO).** `___isPlatformVersionAtLeast` (emitted by + `#available`) is a clang/compiler-rt builtin unresolved in the agent; real SwiftUI + code hits it constantly. Provide it (add the compiler-rt builtins archive to the + session, or supply the symbol). + Verify (all three): enabling `ExamplesSplitE2ETests.splitRendersRealPreviewInAgent` renders `Summary.swift` to a non-empty PNG. - **G2 (deferred, separable)** — `FileWatcher` delivers the **changed path**, not just "something changed". Feeds `hotFile` so the live daemon picks the hot file From ef497b021c1e423cdc8be5320532e467f211255d Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Thu, 4 Jun 2026 13:03:03 -0400 Subject: [PATCH 36/46] JIT executor Phase 4 (G3-b): dlopen the target's binary frameworks in the JIT agent (#191) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After G3-a, the real Tier-2 E2E still failed on the Lottie binary framework: the split's stable module bundles ToDoView.swift, which uses Lottie, so the agent must load it. G3-b loads the target's -F/-framework deps into the agent. - C API previewsmcp_jit_session_add_dylib → EPCDynamicLibrarySearchGenerator::Load (dlopens the library in the agent over EPC and resolves its symbols). - Swift JITSession.addDylib; JITRenderBuild.dylibPaths, parsed from -F/-framework to /.framework/ by PreviewSession.dependencyDylibs. - StructuralReloader.renderObject gains dylibPaths; the reloader adds dylibs before archives and objects. Standalone passes [] (unchanged). ArchiveLoadingTests.resolvesSymbolFromDylibInAgent proves a JIT object resolves a symbol from a dylib dlopen'd in the agent. In the real E2E the Lottie symbols now resolve; the only remaining unresolved symbol is the compiler-rt builtin ___isPlatformVersionAtLeast (G3-c). The render test stays .disabled on G3-c. Co-Authored-By: Claude Opus 4.8 --- Sources/PreviewsCore/PreviewSession.swift | 44 ++++++++++++++++++- Sources/PreviewsCore/StructuralReloader.swift | 12 ++--- .../JITStructuralReloader.swift | 6 ++- Sources/PreviewsJITLink/PreviewsJITLink.swift | 6 +++ .../PreviewsJITLinkCxx/PreviewsJITLinkCxx.cpp | 12 +++++ .../include/PreviewsJITLinkCxx.h | 3 ++ Sources/PreviewsMacOS/HostApp.swift | 6 ++- .../StructuralReloaderTests.swift | 6 ++- ...MacOSPreviewHandleAgentSnapshotTests.swift | 3 +- .../ArchiveLoadingTests.swift | 28 ++++++++++++ .../ExamplesSplitE2ETests.swift | 14 +++--- .../JITStructuralReloaderTests.swift | 9 ++-- .../PreviewSessionSplitTests.swift | 1 + .../StructuralReloadLatencyTests.swift | 6 ++- .../PreviewHostJITReloadTests.swift | 3 +- docs/jit-executor-phase3-plan.md | 13 ++++-- 16 files changed, 142 insertions(+), 30 deletions(-) diff --git a/Sources/PreviewsCore/PreviewSession.swift b/Sources/PreviewsCore/PreviewSession.swift index a2a138f..76365b1 100644 --- a/Sources/PreviewsCore/PreviewSession.swift +++ b/Sources/PreviewsCore/PreviewSession.swift @@ -23,6 +23,9 @@ public struct JITRenderBuild: Sendable { /// 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, @@ -31,7 +34,8 @@ public struct JITRenderBuild: Sendable { entrySymbol: String, literals: [LiteralEntry], supportObjectPaths: [URL] = [], - archivePaths: [URL] = [] + archivePaths: [URL] = [], + dylibPaths: [URL] = [] ) { self.objectPath = objectPath self.imagePath = imagePath @@ -40,6 +44,7 @@ public struct JITRenderBuild: Sendable { self.literals = literals self.supportObjectPaths = supportObjectPaths self.archivePaths = archivePaths + self.dylibPaths = dylibPaths } } @@ -231,10 +236,12 @@ public actor PreviewSession { 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) + dylibPaths = Self.dependencyDylibs(in: ctx.compilerFlags) objectPath = try await compiler.compileObject( source: generated.source, moduleName: "PreviewEdit_\(ctx.moduleName)", @@ -258,7 +265,8 @@ public actor PreviewSession { entrySymbol: "renderPreviewToFile", literals: generated.literals, supportObjectPaths: supportObjectPaths, - archivePaths: archivePaths + archivePaths: archivePaths, + dylibPaths: dylibPaths ) lastJITBuild = build return build @@ -296,6 +304,38 @@ public actor PreviewSession { 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. diff --git a/Sources/PreviewsCore/StructuralReloader.swift b/Sources/PreviewsCore/StructuralReloader.swift index 7346b5f..f50aede 100644 --- a/Sources/PreviewsCore/StructuralReloader.swift +++ b/Sources/PreviewsCore/StructuralReloader.swift @@ -10,11 +10,13 @@ import Foundation /// 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 `objectPath`'s entry, first linking any `archivePaths` (the target's - /// dependency archives) and `supportObjectPaths` (the prebuilt stable-module objects - /// from the recompile-narrowing split). Both are empty for the standalone path, where - /// `objectPath` is self-contained. + /// Render `objectPath`'s entry, first linking the target's dependencies: `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 renderObject( - at objectPath: URL, supportObjectPaths: [URL], archivePaths: [URL], entrySymbol: String + at objectPath: URL, supportObjectPaths: [URL], archivePaths: [URL], dylibPaths: [URL], + entrySymbol: String ) async throws } diff --git a/Sources/PreviewsJITLink/JITStructuralReloader.swift b/Sources/PreviewsJITLink/JITStructuralReloader.swift index e0e78f7..c6ddd73 100644 --- a/Sources/PreviewsJITLink/JITStructuralReloader.swift +++ b/Sources/PreviewsJITLink/JITStructuralReloader.swift @@ -10,9 +10,13 @@ public struct JITStructuralReloader: StructuralReloader { public init() {} public func renderObject( - at objectPath: URL, supportObjectPaths: [URL], archivePaths: [URL], entrySymbol: String + at objectPath: URL, supportObjectPaths: [URL], archivePaths: [URL], dylibPaths: [URL], + entrySymbol: String ) async throws { let session = try JITSession(remoteAgentPath: JITSession.bundledAgentPath()) + for dylib in dylibPaths { + try session.addDylib(path: dylib.path) + } for archive in archivePaths { try session.addArchive(path: archive.path) } diff --git a/Sources/PreviewsJITLink/PreviewsJITLink.swift b/Sources/PreviewsJITLink/PreviewsJITLink.swift index f3ef1e0..d09fcb4 100644 --- a/Sources/PreviewsJITLink/PreviewsJITLink.swift +++ b/Sources/PreviewsJITLink/PreviewsJITLink.swift @@ -83,6 +83,12 @@ public final class JITSession { } } + public func addDylib(path: String) throws { + if let error = previewsmcp_jit_session_add_dylib(handle, path) { + 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 a013842..3e6c6f3 100644 --- a/Sources/PreviewsJITLinkCxx/PreviewsJITLinkCxx.cpp +++ b/Sources/PreviewsJITLinkCxx/PreviewsJITLinkCxx.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -512,6 +513,17 @@ previewsmcp_jit_session_add_archive(previewsmcp_jit_session *session, 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_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 43e2698..87366c2 100644 --- a/Sources/PreviewsJITLinkCxx/include/PreviewsJITLinkCxx.h +++ b/Sources/PreviewsJITLinkCxx/include/PreviewsJITLinkCxx.h @@ -42,6 +42,9 @@ const char *_Nullable previewsmcp_jit_session_add_object( 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_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 8063a3f..67f05f2 100644 --- a/Sources/PreviewsMacOS/HostApp.swift +++ b/Sources/PreviewsMacOS/HostApp.swift @@ -358,7 +358,8 @@ public class PreviewHost: NSObject, NSApplicationDelegate { let build = try await session.compileObjectForJIT() try await reloader.renderObject( at: build.objectPath, supportObjectPaths: build.supportObjectPaths, - archivePaths: build.archivePaths, entrySymbol: build.entrySymbol) + archivePaths: build.archivePaths, dylibPaths: build.dylibPaths, + entrySymbol: build.entrySymbol) agentImagePaths[sessionID] = build.imagePath return build.imagePath } @@ -383,7 +384,8 @@ public class PreviewHost: NSObject, NSApplicationDelegate { else { return nil } try await reloader.renderObject( at: build.objectPath, supportObjectPaths: build.supportObjectPaths, - archivePaths: build.archivePaths, entrySymbol: build.entrySymbol) + archivePaths: build.archivePaths, dylibPaths: build.dylibPaths, + entrySymbol: build.entrySymbol) agentImagePaths[sessionID] = build.imagePath return build.imagePath } diff --git a/Tests/PreviewsCoreTests/StructuralReloaderTests.swift b/Tests/PreviewsCoreTests/StructuralReloaderTests.swift index de4fef3..27bfb3b 100644 --- a/Tests/PreviewsCoreTests/StructuralReloaderTests.swift +++ b/Tests/PreviewsCoreTests/StructuralReloaderTests.swift @@ -9,7 +9,8 @@ struct StructuralReloaderTests { private actor MockReloader: StructuralReloader { private(set) var calls: [(objectPath: URL, entrySymbol: String)] = [] func renderObject( - at objectPath: URL, supportObjectPaths: [URL], archivePaths: [URL], entrySymbol: String + at objectPath: URL, supportObjectPaths: [URL], archivePaths: [URL], dylibPaths: [URL], + entrySymbol: String ) async throws { calls.append((objectPath: objectPath, entrySymbol: entrySymbol)) } @@ -51,7 +52,8 @@ struct StructuralReloaderTests { let reloader = MockReloader() try await reloader.renderObject( at: build.objectPath, supportObjectPaths: build.supportObjectPaths, - archivePaths: build.archivePaths, entrySymbol: build.entrySymbol) + archivePaths: build.archivePaths, dylibPaths: build.dylibPaths, + entrySymbol: build.entrySymbol) let calls = await reloader.recorded() #expect(calls.count == 1) #expect(calls.first?.objectPath == build.objectPath) diff --git a/Tests/PreviewsEngineTests/MacOSPreviewHandleAgentSnapshotTests.swift b/Tests/PreviewsEngineTests/MacOSPreviewHandleAgentSnapshotTests.swift index a407721..ac93a54 100644 --- a/Tests/PreviewsEngineTests/MacOSPreviewHandleAgentSnapshotTests.swift +++ b/Tests/PreviewsEngineTests/MacOSPreviewHandleAgentSnapshotTests.swift @@ -12,7 +12,8 @@ struct MacOSPreviewHandleAgentSnapshotTests { final class RecordingReloader: StructuralReloader, @unchecked Sendable { func renderObject( - at objectPath: URL, supportObjectPaths: [URL], archivePaths: [URL], entrySymbol: String + at objectPath: URL, supportObjectPaths: [URL], archivePaths: [URL], dylibPaths: [URL], + entrySymbol: String ) async throws {} } diff --git a/Tests/PreviewsJITLinkTests/ArchiveLoadingTests.swift b/Tests/PreviewsJITLinkTests/ArchiveLoadingTests.swift index 443cd50..21e2966 100644 --- a/Tests/PreviewsJITLinkTests/ArchiveLoadingTests.swift +++ b/Tests/PreviewsJITLinkTests/ArchiveLoadingTests.swift @@ -46,4 +46,32 @@ struct ArchiveLoadingTests { 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/ExamplesSplitE2ETests.swift b/Tests/PreviewsJITLinkTests/ExamplesSplitE2ETests.swift index f4e0f81..b44f4a9 100644 --- a/Tests/PreviewsJITLinkTests/ExamplesSplitE2ETests.swift +++ b/Tests/PreviewsJITLinkTests/ExamplesSplitE2ETests.swift @@ -9,12 +9,11 @@ import Testing /// flags, real multi-file target, sibling + cross-package deps), driven through /// `PreviewSession.compileObjectForJIT()`. /// -/// The split COMPILE is validated here, and G3-a (static dependency archives) now lets the -/// agent resolve archived sibling/cross-package symbols (ToDoExtras / LocalDep). Rendering a -/// real Tier-2 preview is still blocked on G3-b (dlopen the target's binary frameworks, e.g. -/// Lottie, in the agent) and G3-c (the compiler-rt builtin `___isPlatformVersionAtLeast` -/// emitted by `#available`); the render test below stays disabled until those land. See -/// `docs/jit-executor-phase3-plan.md`. +/// The split COMPILE is validated here. G3-a (static dependency archives) resolves archived +/// sibling/cross-package symbols (ToDoExtras / LocalDep) and G3-b (dlopen binary frameworks +/// in the agent) resolves Lottie. Rendering a real Tier-2 preview is now blocked only on G3-c +/// (the compiler-rt builtin `___isPlatformVersionAtLeast` emitted by `#available`); the render +/// test below stays disabled until G3-c lands. See `docs/jit-executor-phase3-plan.md`. @Suite(.serialized) struct ExamplesSplitE2ETests { static let repoRoot: URL = URL(fileURLWithPath: #filePath) @@ -69,7 +68,7 @@ struct ExamplesSplitE2ETests { @Test( .disabled( - "G3-a (static archives) lands here and resolves ToDoExtras/LocalDep. Still blocked: the stable module bundles ToDoView.swift, which uses the Lottie binary framework (needs G3-b: dlopen the framework in the agent) and emits `___isPlatformVersionAtLeast` from #available (needs G3-c: the compiler-rt builtin). Enable once G3-b/G3-c land." + "G3-a (static archives, ToDoExtras/LocalDep) and G3-b (dlopen the Lottie framework in the agent) now resolve. The only remaining unresolved symbol is the compiler-rt builtin `___isPlatformVersionAtLeast` (emitted by #available). Enable once G3-c lands." )) func splitRendersRealPreviewInAgent() async throws { let hot = Self.spmRoot.appendingPathComponent("Sources/ToDo/Summary.swift") @@ -83,6 +82,7 @@ struct ExamplesSplitE2ETests { at: build.objectPath, supportObjectPaths: build.supportObjectPaths, archivePaths: build.archivePaths, + dylibPaths: build.dylibPaths, entrySymbol: build.entrySymbol ) let png = try Data(contentsOf: build.imagePath) diff --git a/Tests/PreviewsJITLinkTests/JITStructuralReloaderTests.swift b/Tests/PreviewsJITLinkTests/JITStructuralReloaderTests.swift index 170e665..11ec04f 100644 --- a/Tests/PreviewsJITLinkTests/JITStructuralReloaderTests.swift +++ b/Tests/PreviewsJITLinkTests/JITStructuralReloaderTests.swift @@ -33,7 +33,8 @@ struct JITStructuralReloaderTests { let reloader = JITStructuralReloader() try await reloader.renderObject( at: build.objectPath, supportObjectPaths: build.supportObjectPaths, - archivePaths: build.archivePaths, entrySymbol: build.entrySymbol) + archivePaths: build.archivePaths, dylibPaths: build.dylibPaths, + entrySymbol: build.entrySymbol) let data = try Data(contentsOf: build.imagePath) #expect(!data.isEmpty) @@ -75,7 +76,8 @@ struct JITStructuralReloaderTests { try await reloader.renderObject( at: build.objectPath, supportObjectPaths: build.supportObjectPaths, - archivePaths: build.archivePaths, entrySymbol: build.entrySymbol) + archivePaths: build.archivePaths, dylibPaths: build.dylibPaths, + entrySymbol: build.entrySymbol) let b1 = try Self.centerBrightness(build.imagePath) #expect(b1 < 0.4) @@ -90,7 +92,8 @@ struct JITStructuralReloaderTests { try await reloader.renderObject( at: build.objectPath, supportObjectPaths: build.supportObjectPaths, - archivePaths: build.archivePaths, entrySymbol: build.entrySymbol) + archivePaths: build.archivePaths, dylibPaths: build.dylibPaths, + entrySymbol: build.entrySymbol) let b2 = try Self.centerBrightness(build.imagePath) #expect(b2 > 0.7) #expect(b2 > b1) diff --git a/Tests/PreviewsJITLinkTests/PreviewSessionSplitTests.swift b/Tests/PreviewsJITLinkTests/PreviewSessionSplitTests.swift index 654fa76..c94931d 100644 --- a/Tests/PreviewsJITLinkTests/PreviewSessionSplitTests.swift +++ b/Tests/PreviewsJITLinkTests/PreviewSessionSplitTests.swift @@ -139,6 +139,7 @@ struct PreviewSessionSplitTests { at: build.objectPath, supportObjectPaths: build.supportObjectPaths, archivePaths: build.archivePaths, + dylibPaths: build.dylibPaths, entrySymbol: build.entrySymbol ) diff --git a/Tests/PreviewsJITLinkTests/StructuralReloadLatencyTests.swift b/Tests/PreviewsJITLinkTests/StructuralReloadLatencyTests.swift index 61abc10..dc763c7 100644 --- a/Tests/PreviewsJITLinkTests/StructuralReloadLatencyTests.swift +++ b/Tests/PreviewsJITLinkTests/StructuralReloadLatencyTests.swift @@ -38,7 +38,8 @@ struct StructuralReloadLatencyTests { let warm = try await session.compileObjectForJIT() try await reloader.renderObject( at: warm.objectPath, supportObjectPaths: warm.supportObjectPaths, - archivePaths: warm.archivePaths, entrySymbol: warm.entrySymbol) + archivePaths: warm.archivePaths, dylibPaths: warm.dylibPaths, + entrySymbol: warm.entrySymbol) let clock = ContinuousClock() let t0 = clock.now @@ -46,7 +47,8 @@ struct StructuralReloadLatencyTests { let t1 = clock.now try await reloader.renderObject( at: build.objectPath, supportObjectPaths: build.supportObjectPaths, - archivePaths: build.archivePaths, entrySymbol: build.entrySymbol) + archivePaths: build.archivePaths, dylibPaths: build.dylibPaths, + entrySymbol: build.entrySymbol) let t2 = clock.now let compileMs = Self.ms(t0.duration(to: t1)) diff --git a/Tests/PreviewsMacOSTests/PreviewHostJITReloadTests.swift b/Tests/PreviewsMacOSTests/PreviewHostJITReloadTests.swift index 98d1da5..963b2b2 100644 --- a/Tests/PreviewsMacOSTests/PreviewHostJITReloadTests.swift +++ b/Tests/PreviewsMacOSTests/PreviewHostJITReloadTests.swift @@ -11,7 +11,8 @@ struct PreviewHostJITReloadTests { final class RecordingReloader: StructuralReloader, @unchecked Sendable { private(set) var calls: [(objectPath: URL, entrySymbol: String)] = [] func renderObject( - at objectPath: URL, supportObjectPaths: [URL], archivePaths: [URL], entrySymbol: String + at objectPath: URL, supportObjectPaths: [URL], archivePaths: [URL], dylibPaths: [URL], + entrySymbol: String ) async throws { calls.append((objectPath: objectPath, entrySymbol: entrySymbol)) } diff --git a/docs/jit-executor-phase3-plan.md b/docs/jit-executor-phase3-plan.md index 31f538b..433a022 100644 --- a/docs/jit-executor-phase3-plan.md +++ b/docs/jit-executor-phase3-plan.md @@ -742,10 +742,15 @@ reuse across edits, flat 180ms vs 312ms whole at N=24). The editable unit reuses `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 (TODO).** `-F/-framework` deps (e.g. `Lottie.framework`) - are binary dylibs. The agent must `dlopen` them, via - `EPCDynamicLibrarySearchGenerator::Load` over EPC (resolve the framework binary at - `/.framework/`). + - **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 builtin (TODO).** `___isPlatformVersionAtLeast` (emitted by `#available`) is a clang/compiler-rt builtin unresolved in the agent; real SwiftUI code hits it constantly. Provide it (add the compiler-rt builtins archive to the From 04e9ddf55a71fbc330c70424019fa5981be49739 Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Thu, 4 Jun 2026 13:08:48 -0400 Subject: [PATCH 37/46] JIT executor Phase 4 (G3-c): load compiler-rt builtins; real Tier-2 preview renders (#191) The last unresolved symbol after G3-a/G3-b was the compiler-rt builtin ___isPlatformVersionAtLeast (emitted by #available, hit by all real SwiftUI). It is a defined symbol in the toolchain's libclang_rt.osx.a. - Toolchain.compilerRuntimeArchivePath() locates it via `xcrun clang -print-runtime-dir` + libclang_rt.osx.a. - PreviewSession's split branch appends it to archivePaths, so it loads via the existing G3-a static-archive mechanism (lazy: pulls only referenced builtins). This completes the JIT agent's Tier-2 dependency closure. The examples E2E render test (splitRendersRealPreviewInAgent) is now ENABLED and renders Summary.swift from examples/spm (with ToDoExtras + Lottie deps) to a non-empty PNG end to end. The earlier NSForwarding/JSONObjectWithData abort was teardown noise after the failing link; it clears once the link succeeds. With G3 complete, #191 item 3 (examples E2E) is fully done: a real Tier-2 structural edit recompiles only the hot file and renders in the agent. Co-Authored-By: Claude Opus 4.8 --- Sources/PreviewsCore/PreviewSession.swift | 3 +++ Sources/PreviewsCore/Toolchain.swift | 11 +++++++++++ .../ExamplesSplitE2ETests.swift | 15 +++++---------- docs/jit-executor-phase3-plan.md | 16 ++++++++++------ 4 files changed, 29 insertions(+), 16 deletions(-) diff --git a/Sources/PreviewsCore/PreviewSession.swift b/Sources/PreviewsCore/PreviewSession.swift index 76365b1..e662f79 100644 --- a/Sources/PreviewsCore/PreviewSession.swift +++ b/Sources/PreviewsCore/PreviewSession.swift @@ -241,6 +241,9 @@ public actor PreviewSession { 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, 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/Tests/PreviewsJITLinkTests/ExamplesSplitE2ETests.swift b/Tests/PreviewsJITLinkTests/ExamplesSplitE2ETests.swift index b44f4a9..cb27f09 100644 --- a/Tests/PreviewsJITLinkTests/ExamplesSplitE2ETests.swift +++ b/Tests/PreviewsJITLinkTests/ExamplesSplitE2ETests.swift @@ -9,11 +9,10 @@ import Testing /// flags, real multi-file target, sibling + cross-package deps), driven through /// `PreviewSession.compileObjectForJIT()`. /// -/// The split COMPILE is validated here. G3-a (static dependency archives) resolves archived -/// sibling/cross-package symbols (ToDoExtras / LocalDep) and G3-b (dlopen binary frameworks -/// in the agent) resolves Lottie. Rendering a real Tier-2 preview is now blocked only on G3-c -/// (the compiler-rt builtin `___isPlatformVersionAtLeast` emitted by `#available`); the render -/// test below stays disabled until G3-c lands. See `docs/jit-executor-phase3-plan.md`. +/// 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) @@ -66,11 +65,7 @@ struct ExamplesSplitE2ETests { #expect(build2.objectPath == build1.objectPath) } - @Test( - .disabled( - "G3-a (static archives, ToDoExtras/LocalDep) and G3-b (dlopen the Lottie framework in the agent) now resolve. The only remaining unresolved symbol is the compiler-rt builtin `___isPlatformVersionAtLeast` (emitted by #available). Enable once G3-c lands." - )) - func splitRendersRealPreviewInAgent() async throws { + @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() diff --git a/docs/jit-executor-phase3-plan.md b/docs/jit-executor-phase3-plan.md index 433a022..95eba56 100644 --- a/docs/jit-executor-phase3-plan.md +++ b/docs/jit-executor-phase3-plan.md @@ -751,12 +751,16 @@ reuse across edits, flat 180ms vs 312ms whole at N=24). The editable unit reuses 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 builtin (TODO).** `___isPlatformVersionAtLeast` (emitted by - `#available`) is a clang/compiler-rt builtin unresolved in the agent; real SwiftUI - code hits it constantly. Provide it (add the compiler-rt builtins archive to the - session, or supply the symbol). - Verify (all three): enabling `ExamplesSplitE2ETests.splitRendersRealPreviewInAgent` - renders `Summary.swift` to a non-empty PNG. + - **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. From 1392dd3e418da956041b753f18777406b5855438 Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Thu, 4 Jun 2026 15:27:52 -0400 Subject: [PATCH 38/46] JIT executor Phase 4 (item 2, chunk 1): newGeneration for capped-persistent reuse (#191) Add previewsmcp_jit_session_new_generation: create a fresh JITDylib on the same agent's LLJIT, point the session at it, and reset the initialized flag so the next run_on_main re-runs LLJIT::initialize on the new JD (re-registers __swift5_* metadata, which segfaults if skipped). Swift JITSession.newGeneration wraps it. This lets one persistent agent serve many edits, each isolated in its own JD, without respawning. CappedPersistentTests reuses one session across five generations, each linking a distinct-color render object and rendering its own color, proving fresh-JD isolation + per-generation re-initialize with a single agent. Next (chunk 2): make JITStructuralReloader an actor that holds the persistent session, calls newGeneration per edit, and respawns at a generation cap. Co-Authored-By: Claude Opus 4.8 --- Sources/PreviewsJITLink/PreviewsJITLink.swift | 9 +++ .../PreviewsJITLinkCxx/PreviewsJITLinkCxx.cpp | 13 ++++ .../include/PreviewsJITLinkCxx.h | 3 + .../CappedPersistentTests.swift | 62 +++++++++++++++++++ 4 files changed, 87 insertions(+) create mode 100644 Tests/PreviewsJITLinkTests/CappedPersistentTests.swift diff --git a/Sources/PreviewsJITLink/PreviewsJITLink.swift b/Sources/PreviewsJITLink/PreviewsJITLink.swift index d09fcb4..1eea593 100644 --- a/Sources/PreviewsJITLink/PreviewsJITLink.swift +++ b/Sources/PreviewsJITLink/PreviewsJITLink.swift @@ -89,6 +89,15 @@ public final class JITSession { } } + /// 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 3e6c6f3..378d95e 100644 --- a/Sources/PreviewsJITLinkCxx/PreviewsJITLinkCxx.cpp +++ b/Sources/PreviewsJITLinkCxx/PreviewsJITLinkCxx.cpp @@ -524,6 +524,19 @@ const char *previewsmcp_jit_session_add_dylib(previewsmcp_jit_session *session, 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 87366c2..17d5817 100644 --- a/Sources/PreviewsJITLinkCxx/include/PreviewsJITLinkCxx.h +++ b/Sources/PreviewsJITLinkCxx/include/PreviewsJITLinkCxx.h @@ -45,6 +45,9 @@ const char *_Nullable previewsmcp_jit_session_add_archive( 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/Tests/PreviewsJITLinkTests/CappedPersistentTests.swift b/Tests/PreviewsJITLinkTests/CappedPersistentTests.swift new file mode 100644 index 0000000..237f5e9 --- /dev/null +++ b/Tests/PreviewsJITLinkTests/CappedPersistentTests.swift @@ -0,0 +1,62 @@ +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 + } + } + """ + } + + @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)) + } + } +} From c74e8d3c52f96277ba42a49301b6364191d873c6 Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Thu, 4 Jun 2026 15:31:27 -0400 Subject: [PATCH 39/46] JIT executor Phase 4 (item 2, chunk 2): capped-persistent actor reloader (#191) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JITStructuralReloader is now an actor that keeps one agent alive across edits. nextSession() links each edit into a fresh JITDylib (newGeneration) under the cap and respawns (replacing the session, whose deinit kills the old agent) at generationCap (default 100). This amortizes the ~70ms agent spawn over many edits and makes the literal path's re-render cheap. The StructuralReloader protocol is unchanged. All existing reloader/latency/E2E tests pass. New: reloaderRespawnsAtGenerationCap (cap=2, 5 renders, crosses two respawns) and splitRendersRealPreviewAcrossGenerations (the real Summary preview rendered twice, so generation 2 re-links ToDoExtras/Lottie/builtins into a fresh JD) — both green, so U2 (duplicate dependency registration across generations) does not crash at low generation counts. Known follow-up (chunk 2b): each generation re-links the editable object, which defines DesignTimeStore under the same module name, so the ObjC runtime warns about a duplicate class registration across generations. Renders still succeed, but it accumulates toward the cap; the fix is a unique -module-name per compile. Captured in docs/jit-executor-phase3-plan.md. Co-Authored-By: Claude Opus 4.8 --- .../JITStructuralReloader.swift | 37 +++++++++++++++---- .../CappedPersistentTests.swift | 33 +++++++++++++++++ .../ExamplesSplitE2ETests.swift | 26 +++++++++++++ docs/jit-executor-phase3-plan.md | 26 +++++++++++++ 4 files changed, 114 insertions(+), 8 deletions(-) diff --git a/Sources/PreviewsJITLink/JITStructuralReloader.swift b/Sources/PreviewsJITLink/JITStructuralReloader.swift index c6ddd73..7f17a5d 100644 --- a/Sources/PreviewsJITLink/JITStructuralReloader.swift +++ b/Sources/PreviewsJITLink/JITStructuralReloader.swift @@ -1,19 +1,25 @@ import Foundation import PreviewsCore -/// `StructuralReloader` backed by the remote JIT agent. Spawns a fresh agent, links the -/// object, and runs its render entry on the agent's main thread; the entry writes the -/// preview PNG to the path baked into the object at compile time. The session's `deinit` -/// kills the agent, so this is respawn-per-edit; a capped-persistent variant can replace -/// the body without changing the protocol. -public struct JITStructuralReloader: StructuralReloader { - public init() {} +/// `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 + + public init(generationCap: Int = 100) { + self.generationCap = generationCap + } public func renderObject( at objectPath: URL, supportObjectPaths: [URL], archivePaths: [URL], dylibPaths: [URL], entrySymbol: String ) async throws { - let session = try JITSession(remoteAgentPath: JITSession.bundledAgentPath()) + let session = try nextSession() for dylib in dylibPaths { try session.addDylib(path: dylib.path) } @@ -29,6 +35,21 @@ public struct JITStructuralReloader: StructuralReloader { 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 { diff --git a/Tests/PreviewsJITLinkTests/CappedPersistentTests.swift b/Tests/PreviewsJITLinkTests/CappedPersistentTests.swift index 237f5e9..03bf025 100644 --- a/Tests/PreviewsJITLinkTests/CappedPersistentTests.swift +++ b/Tests/PreviewsJITLinkTests/CappedPersistentTests.swift @@ -31,6 +31,39 @@ struct CappedPersistentTests { """ } + @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.renderObject( + at: build.objectPath, + supportObjectPaths: build.supportObjectPaths, + archivePaths: build.archivePaths, + dylibPaths: build.dylibPaths, + entrySymbol: build.entrySymbol + ) + 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)] diff --git a/Tests/PreviewsJITLinkTests/ExamplesSplitE2ETests.swift b/Tests/PreviewsJITLinkTests/ExamplesSplitE2ETests.swift index cb27f09..6fdcfad 100644 --- a/Tests/PreviewsJITLinkTests/ExamplesSplitE2ETests.swift +++ b/Tests/PreviewsJITLinkTests/ExamplesSplitE2ETests.swift @@ -84,4 +84,30 @@ struct ExamplesSplitE2ETests { #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.renderObject( + at: build.objectPath, + supportObjectPaths: build.supportObjectPaths, + archivePaths: build.archivePaths, + dylibPaths: build.dylibPaths, + entrySymbol: build.entrySymbol + ) + let png = try Data(contentsOf: build.imagePath) + #expect(!png.isEmpty) + #expect(NSBitmapImageRep(data: png) != nil) + } + } } diff --git a/docs/jit-executor-phase3-plan.md b/docs/jit-executor-phase3-plan.md index 95eba56..e7d3878 100644 --- a/docs/jit-executor-phase3-plan.md +++ b/docs/jit-executor-phase3-plan.md @@ -506,6 +506,32 @@ to recompile. The watch scope is already ~"project sources minus dependencies" - **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 — OPEN (duplicate-class risk).** Each generation re-links the editable + object, which defines `DesignTimeStore` under the **same module name**, so the ObjC + runtime warns `Class _TtC…DesignTimeStore is implemented in both …` (duplicate + registration across generations). Renders still succeed, but the warning says it + "may cause spurious casting failures and mysterious crashes," and it accumulates + toward the cap (the soak never hit this — minimal object, no such class). Fix: + give the editable unit a **unique `-module-name` per compile** (e.g. + `PreviewEdit__`; the stable module name stays `ctx.moduleName` + for `@testable`), so each generation's classes mangle distinctly. Verify: render the + same preview many times through one reloader with no `objc[...] implemented in both` + warning. + ### 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 From 82a05bfae4bcdbd045e95a610ee2ca4fe6141d96 Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Thu, 4 Jun 2026 15:49:12 -0400 Subject: [PATCH 40/46] JIT executor Phase 4 (item 2, chunk 2b): unique editable module + in-place literal re-render (#191) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The capped-persistent agent re-registered the same DesignTimeStore ObjC class across generations, warning "Class _TtC...DesignTimeStore is implemented in both ...". Two causes, both fixed: 1. Distinct structural compiles reused the editable module name. Give the editable unit a unique -module-name per compile (PreviewSession.uniqueModuleToken = "g" + fresh UUID hex; the stable module stays ctx.moduleName for @testable), so each generation's classes mangle distinctly. 2. The literal re-render re-linked the SAME object into a new generation. The reloader now re-runs the entry in place when objectPath == lastObjectPath (no newGeneration, no re-link) — also the design's in-place ~10ms literal path. Verified by editableModuleNameIsUniquePerCompile (two compiles produce disjoint _$s...DesignTimeStore symbols) and the full JIT suite rendering across generations with zero "implemented in both" warnings. Note: the token is computed statelessly on purpose. Storing a counter property on the PreviewSession actor shifted its layout and deterministically surfaced a latent EXC_ARM_DA_ALIGN (SIGBUS) in the async main-queue drain at exit of a mock-reloader test that never runs JIT'd code (a trivial whitespace recompile did not trigger it). The stateless token adds no stored property and sidesteps it; the underlying latent corruption is uncharacterized and flagged in the plan. Co-Authored-By: Claude Opus 4.8 --- Sources/PreviewsCore/PreviewSession.swift | 11 ++++- .../JITStructuralReloader.swift | 14 ++++++ .../CappedPersistentTests.swift | 43 +++++++++++++++++++ docs/jit-executor-phase3-plan.md | 33 +++++++++----- 4 files changed, 88 insertions(+), 13 deletions(-) diff --git a/Sources/PreviewsCore/PreviewSession.swift b/Sources/PreviewsCore/PreviewSession.swift index e662f79..20cc5f3 100644 --- a/Sources/PreviewsCore/PreviewSession.swift +++ b/Sources/PreviewsCore/PreviewSession.swift @@ -247,13 +247,13 @@ public actor PreviewSession { dylibPaths = Self.dependencyDylibs(in: ctx.compilerFlags) objectPath = try await compiler.compileObject( source: generated.source, - moduleName: "PreviewEdit_\(ctx.moduleName)", + 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) + moduleName: "\(Self.moduleName(for: sourceFile))_\(Self.uniqueModuleToken())" ) } try Self.writeDesignTimeValues(generated.literals, to: valuesPath) @@ -459,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/PreviewsJITLink/JITStructuralReloader.swift b/Sources/PreviewsJITLink/JITStructuralReloader.swift index 7f17a5d..efd5406 100644 --- a/Sources/PreviewsJITLink/JITStructuralReloader.swift +++ b/Sources/PreviewsJITLink/JITStructuralReloader.swift @@ -10,6 +10,7 @@ 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 @@ -19,6 +20,14 @@ public actor JITStructuralReloader: StructuralReloader { at objectPath: URL, supportObjectPaths: [URL], archivePaths: [URL], dylibPaths: [URL], entrySymbol: String ) 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, objectPath == lastObjectPath { + try Self.run(session, entrySymbol) + return + } + let session = try nextSession() for dylib in dylibPaths { try session.addDylib(path: dylib.path) @@ -30,6 +39,11 @@ public actor JITStructuralReloader: StructuralReloader { try session.addObject(path: support.path) } try session.addObject(path: objectPath.path) + lastObjectPath = objectPath + try Self.run(session, 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) diff --git a/Tests/PreviewsJITLinkTests/CappedPersistentTests.swift b/Tests/PreviewsJITLinkTests/CappedPersistentTests.swift index 03bf025..717d25c 100644 --- a/Tests/PreviewsJITLinkTests/CappedPersistentTests.swift +++ b/Tests/PreviewsJITLinkTests/CappedPersistentTests.swift @@ -31,6 +31,49 @@ struct CappedPersistentTests { """ } + 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) diff --git a/docs/jit-executor-phase3-plan.md b/docs/jit-executor-phase3-plan.md index e7d3878..8509ebc 100644 --- a/docs/jit-executor-phase3-plan.md +++ b/docs/jit-executor-phase3-plan.md @@ -520,17 +520,28 @@ One agent serves many edits, each in a fresh `JITDylib`, respawning at a cap. `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 — OPEN (duplicate-class risk).** Each generation re-links the editable - object, which defines `DesignTimeStore` under the **same module name**, so the ObjC - runtime warns `Class _TtC…DesignTimeStore is implemented in both …` (duplicate - registration across generations). Renders still succeed, but the warning says it - "may cause spurious casting failures and mysterious crashes," and it accumulates - toward the cap (the soak never hit this — minimal object, no such class). Fix: - give the editable unit a **unique `-module-name` per compile** (e.g. - `PreviewEdit__`; the stable module name stays `ctx.moduleName` - for `@testable`), so each generation's classes mangle distinctly. Verify: render the - same preview many times through one reloader with no `objc[...] implemented in both` - warning. +- **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. ### Model mismatch — RESOLVED (by W5+W7) The design's fast path assumes edits land in the **editable/preview layer** on From dc7c7b65beb8e90b89665b7a21dd9b1b77d6f643 Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Thu, 4 Jun 2026 16:19:50 -0400 Subject: [PATCH 41/46] JIT executor Phase 4 (item 4): run JIT tests in CI behind a cached LLVM (#191) CI skipped the JIT targets because they are gated on third_party/llvm-build (+ llvm-build-rt + the llvm-project source headers), which runners lack. Building that pinned Swift-fork LLVM from source is slow, so it is cached. - cache-warm.yml gains warm-jit-cache: builds via scripts/build-jit-llvm.sh and caches the three third_party dirs, keyed on hashFiles(build-jit-llvm.sh) so it only rebuilds when the recipe / pinned SHA changes. Weekly cron + dispatch keep it warm; the build skips on a cache hit. - ci.yml gains jit-tests (gated on changes.src): restores the cache, builds from source on a miss (90m timeout absorbs it), builds the package + SPM example, then runs swift test --filter PreviewsJITLinkTests. First PR run cache-misses and pays the full libLLVM build; after merge, dispatch warm-jit-cache once so main's cache is warm and later PRs just restore. The build guard and `swift test --filter PreviewsJITLinkTests` are already proven locally; the GHA cache/build behavior is verified by watching the jit-tests job. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/cache-warm.yml | 29 +++++++++++++++ .github/workflows/ci.yml | 62 ++++++++++++++++++++++++++++++++ docs/jit-executor-phase3-plan.md | 17 +++++++++ 3 files changed, 108 insertions(+) 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..8d9948e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -268,3 +268,65 @@ 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 + run: sudo mdutil -a -i off + + - 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. + timeout-minutes: 20 + env: + NSUnbufferedIO: "YES" + run: swift test --filter "PreviewsJITLinkTests" diff --git a/docs/jit-executor-phase3-plan.md b/docs/jit-executor-phase3-plan.md index 8509ebc..6f08f9a 100644 --- a/docs/jit-executor-phase3-plan.md +++ b/docs/jit-executor-phase3-plan.md @@ -543,6 +543,23 @@ One agent serves many edits, each in a fresh `JITDylib`, respawning at a cap. `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 From 10a2531c7b6d549b4ce05407aed0ba3b62b4c5de Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Thu, 4 Jun 2026 16:21:18 -0400 Subject: [PATCH 42/46] CI: make jit-tests Disable Spotlight non-fatal (#191) mdutil -a -i off exits 1 when a volume is mid-transition (kMDConfigSearchLevelTransitioning), which aborted the jit-tests job before it could build/test. The step is only a build-speed hint, so tolerate the flake. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8d9948e..cec6365 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -284,7 +284,9 @@ jobs: run: sudo xcode-select -s /Applications/Xcode_26.3.app - name: Disable Spotlight - run: sudo mdutil -a -i off + # 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 From a5c9cbb65b336596d944537c8eb3444a0ad09dbb Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Thu, 4 Jun 2026 16:40:14 -0400 Subject: [PATCH 43/46] CI: run JIT tests serially to avoid LLVM-assertion flakiness (#191) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The JIT suite SIGABRTs intermittently under Swift Testing's default parallel execution: concurrent compile/link/teardown over the shared in-process LLJIT trips LLVM ORC assertions (assertions are on in our build) — varied between SymbolStringPool dangling-reference and SmallVector set_size aborts. Serial is stable (verified 8/8 locally vs ~1-2 crashes per 5-8 parallel runs). Add --no-parallel to the jit-tests step. The deeper fix (thread-safe in-process JIT) is deferred. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cec6365..abeaa57 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -328,7 +328,11 @@ jobs: - 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" + run: swift test --filter "PreviewsJITLinkTests" --no-parallel From 305993f307217c3ccd20972af0f237d543af7ebe Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Thu, 4 Jun 2026 16:43:17 -0400 Subject: [PATCH 44/46] JIT executor Phase 4: consolidate StructuralReloader.render to take the build (#191) renderObject had grown to five arguments (objectPath, supportObjectPaths, archivePaths, dylibPaths, entrySymbol), all of which the JITRenderBuild already carries. Replace it with render(_ build: JITRenderBuild): one argument, no more signature churn when a new link-input category is added. Pure refactor; the daemon callers and every reloader test/mock pass the build they already hold. 50 JIT tests + 8 seam/host tests pass (serial). Co-Authored-By: Claude Opus 4.8 --- Sources/PreviewsCore/StructuralReloader.swift | 7 ++----- .../JITStructuralReloader.swift | 21 ++++++++----------- Sources/PreviewsMacOS/HostApp.swift | 10 ++------- .../StructuralReloaderTests.swift | 12 +++-------- ...MacOSPreviewHandleAgentSnapshotTests.swift | 5 +---- .../CappedPersistentTests.swift | 8 +------ .../ExamplesSplitE2ETests.swift | 16 ++------------ .../JITStructuralReloaderTests.swift | 15 +++---------- .../PreviewSessionSplitTests.swift | 8 +------ .../StructuralReloadLatencyTests.swift | 10 ++------- .../PreviewHostJITReloadTests.swift | 7 ++----- 11 files changed, 28 insertions(+), 91 deletions(-) diff --git a/Sources/PreviewsCore/StructuralReloader.swift b/Sources/PreviewsCore/StructuralReloader.swift index f50aede..16933ad 100644 --- a/Sources/PreviewsCore/StructuralReloader.swift +++ b/Sources/PreviewsCore/StructuralReloader.swift @@ -10,13 +10,10 @@ import Foundation /// 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 `objectPath`'s entry, first linking the target's dependencies: `dylibPaths` + /// 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 renderObject( - at objectPath: URL, supportObjectPaths: [URL], archivePaths: [URL], dylibPaths: [URL], - entrySymbol: String - ) async throws + func render(_ build: JITRenderBuild) async throws } diff --git a/Sources/PreviewsJITLink/JITStructuralReloader.swift b/Sources/PreviewsJITLink/JITStructuralReloader.swift index efd5406..effb368 100644 --- a/Sources/PreviewsJITLink/JITStructuralReloader.swift +++ b/Sources/PreviewsJITLink/JITStructuralReloader.swift @@ -16,31 +16,28 @@ public actor JITStructuralReloader: StructuralReloader { self.generationCap = generationCap } - public func renderObject( - at objectPath: URL, supportObjectPaths: [URL], archivePaths: [URL], dylibPaths: [URL], - entrySymbol: String - ) async throws { + 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, objectPath == lastObjectPath { - try Self.run(session, entrySymbol) + if let session, build.objectPath == lastObjectPath { + try Self.run(session, build.entrySymbol) return } let session = try nextSession() - for dylib in dylibPaths { + for dylib in build.dylibPaths { try session.addDylib(path: dylib.path) } - for archive in archivePaths { + for archive in build.archivePaths { try session.addArchive(path: archive.path) } - for support in supportObjectPaths { + for support in build.supportObjectPaths { try session.addObject(path: support.path) } - try session.addObject(path: objectPath.path) - lastObjectPath = objectPath - try Self.run(session, entrySymbol) + 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 { diff --git a/Sources/PreviewsMacOS/HostApp.swift b/Sources/PreviewsMacOS/HostApp.swift index 67f05f2..7db6970 100644 --- a/Sources/PreviewsMacOS/HostApp.swift +++ b/Sources/PreviewsMacOS/HostApp.swift @@ -356,10 +356,7 @@ public class PreviewHost: NSObject, NSApplicationDelegate { 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.renderObject( - at: build.objectPath, supportObjectPaths: build.supportObjectPaths, - archivePaths: build.archivePaths, dylibPaths: build.dylibPaths, - entrySymbol: build.entrySymbol) + try await reloader.render(build) agentImagePaths[sessionID] = build.imagePath return build.imagePath } @@ -382,10 +379,7 @@ public class PreviewHost: NSObject, NSApplicationDelegate { let reloader = structuralReloader, let build = try await session.applyLiteralValuesForJIT(changes) else { return nil } - try await reloader.renderObject( - at: build.objectPath, supportObjectPaths: build.supportObjectPaths, - archivePaths: build.archivePaths, dylibPaths: build.dylibPaths, - entrySymbol: build.entrySymbol) + try await reloader.render(build) agentImagePaths[sessionID] = build.imagePath return build.imagePath } diff --git a/Tests/PreviewsCoreTests/StructuralReloaderTests.swift b/Tests/PreviewsCoreTests/StructuralReloaderTests.swift index 27bfb3b..dd1834e 100644 --- a/Tests/PreviewsCoreTests/StructuralReloaderTests.swift +++ b/Tests/PreviewsCoreTests/StructuralReloaderTests.swift @@ -8,11 +8,8 @@ struct StructuralReloaderTests { private actor MockReloader: StructuralReloader { private(set) var calls: [(objectPath: URL, entrySymbol: String)] = [] - func renderObject( - at objectPath: URL, supportObjectPaths: [URL], archivePaths: [URL], dylibPaths: [URL], - entrySymbol: String - ) async throws { - calls.append((objectPath: objectPath, entrySymbol: entrySymbol)) + func render(_ build: JITRenderBuild) async throws { + calls.append((objectPath: build.objectPath, entrySymbol: build.entrySymbol)) } func recorded() -> [(objectPath: URL, entrySymbol: String)] { calls } } @@ -50,10 +47,7 @@ struct StructuralReloaderTests { #expect(symbols.contains("_renderPreviewToFile")) let reloader = MockReloader() - try await reloader.renderObject( - at: build.objectPath, supportObjectPaths: build.supportObjectPaths, - archivePaths: build.archivePaths, dylibPaths: build.dylibPaths, - entrySymbol: build.entrySymbol) + try await reloader.render(build) let calls = await reloader.recorded() #expect(calls.count == 1) #expect(calls.first?.objectPath == build.objectPath) diff --git a/Tests/PreviewsEngineTests/MacOSPreviewHandleAgentSnapshotTests.swift b/Tests/PreviewsEngineTests/MacOSPreviewHandleAgentSnapshotTests.swift index ac93a54..7c3d112 100644 --- a/Tests/PreviewsEngineTests/MacOSPreviewHandleAgentSnapshotTests.swift +++ b/Tests/PreviewsEngineTests/MacOSPreviewHandleAgentSnapshotTests.swift @@ -11,10 +11,7 @@ import Testing struct MacOSPreviewHandleAgentSnapshotTests { final class RecordingReloader: StructuralReloader, @unchecked Sendable { - func renderObject( - at objectPath: URL, supportObjectPaths: [URL], archivePaths: [URL], dylibPaths: [URL], - entrySymbol: String - ) async throws {} + func render(_ build: JITRenderBuild) async throws {} } @Test func snapshotReturnsAgentImage() async throws { diff --git a/Tests/PreviewsJITLinkTests/CappedPersistentTests.swift b/Tests/PreviewsJITLinkTests/CappedPersistentTests.swift index 717d25c..4f7ac6a 100644 --- a/Tests/PreviewsJITLinkTests/CappedPersistentTests.swift +++ b/Tests/PreviewsJITLinkTests/CappedPersistentTests.swift @@ -95,13 +95,7 @@ struct CappedPersistentTests { for _ in 0..<5 { let build = try await session.compileObjectForJIT() - try await reloader.renderObject( - at: build.objectPath, - supportObjectPaths: build.supportObjectPaths, - archivePaths: build.archivePaths, - dylibPaths: build.dylibPaths, - entrySymbol: build.entrySymbol - ) + try await reloader.render(build) let data = try Data(contentsOf: build.imagePath) #expect(!data.isEmpty) } diff --git a/Tests/PreviewsJITLinkTests/ExamplesSplitE2ETests.swift b/Tests/PreviewsJITLinkTests/ExamplesSplitE2ETests.swift index 6fdcfad..ce0af8b 100644 --- a/Tests/PreviewsJITLinkTests/ExamplesSplitE2ETests.swift +++ b/Tests/PreviewsJITLinkTests/ExamplesSplitE2ETests.swift @@ -73,13 +73,7 @@ struct ExamplesSplitE2ETests { let build = try await session.compileObjectForJIT() let reloader = JITStructuralReloader() - try await reloader.renderObject( - at: build.objectPath, - supportObjectPaths: build.supportObjectPaths, - archivePaths: build.archivePaths, - dylibPaths: build.dylibPaths, - entrySymbol: build.entrySymbol - ) + try await reloader.render(build) let png = try Data(contentsOf: build.imagePath) #expect(!png.isEmpty) #expect(NSBitmapImageRep(data: png) != nil) @@ -98,13 +92,7 @@ struct ExamplesSplitE2ETests { for _ in 0..<2 { let build = try await session.compileObjectForJIT() - try await reloader.renderObject( - at: build.objectPath, - supportObjectPaths: build.supportObjectPaths, - archivePaths: build.archivePaths, - dylibPaths: build.dylibPaths, - entrySymbol: build.entrySymbol - ) + 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/JITStructuralReloaderTests.swift b/Tests/PreviewsJITLinkTests/JITStructuralReloaderTests.swift index 11ec04f..ffde088 100644 --- a/Tests/PreviewsJITLinkTests/JITStructuralReloaderTests.swift +++ b/Tests/PreviewsJITLinkTests/JITStructuralReloaderTests.swift @@ -31,10 +31,7 @@ struct JITStructuralReloaderTests { let build = try await session.compileObjectForJIT() let reloader = JITStructuralReloader() - try await reloader.renderObject( - at: build.objectPath, supportObjectPaths: build.supportObjectPaths, - archivePaths: build.archivePaths, dylibPaths: build.dylibPaths, - entrySymbol: build.entrySymbol) + try await reloader.render(build) let data = try Data(contentsOf: build.imagePath) #expect(!data.isEmpty) @@ -74,10 +71,7 @@ struct JITStructuralReloaderTests { let build = try await session.compileObjectForJIT() let reloader = JITStructuralReloader() - try await reloader.renderObject( - at: build.objectPath, supportObjectPaths: build.supportObjectPaths, - archivePaths: build.archivePaths, dylibPaths: build.dylibPaths, - entrySymbol: build.entrySymbol) + try await reloader.render(build) let b1 = try Self.centerBrightness(build.imagePath) #expect(b1 < 0.4) @@ -90,10 +84,7 @@ struct JITStructuralReloaderTests { values[whiteLiteral.id] = 0.9 try JSONSerialization.data(withJSONObject: values).write(to: build.valuesPath) - try await reloader.renderObject( - at: build.objectPath, supportObjectPaths: build.supportObjectPaths, - archivePaths: build.archivePaths, dylibPaths: build.dylibPaths, - entrySymbol: build.entrySymbol) + try await reloader.render(build) let b2 = try Self.centerBrightness(build.imagePath) #expect(b2 > 0.7) #expect(b2 > b1) diff --git a/Tests/PreviewsJITLinkTests/PreviewSessionSplitTests.swift b/Tests/PreviewsJITLinkTests/PreviewSessionSplitTests.swift index c94931d..206187f 100644 --- a/Tests/PreviewsJITLinkTests/PreviewSessionSplitTests.swift +++ b/Tests/PreviewsJITLinkTests/PreviewSessionSplitTests.swift @@ -135,13 +135,7 @@ struct PreviewSessionSplitTests { let build = try await session.compileObjectForJIT() let reloader = JITStructuralReloader() - try await reloader.renderObject( - at: build.objectPath, - supportObjectPaths: build.supportObjectPaths, - archivePaths: build.archivePaths, - dylibPaths: build.dylibPaths, - entrySymbol: build.entrySymbol - ) + try await reloader.render(build) let rep = try #require(NSBitmapImageRep(data: Data(contentsOf: build.imagePath))) let color = try #require( diff --git a/Tests/PreviewsJITLinkTests/StructuralReloadLatencyTests.swift b/Tests/PreviewsJITLinkTests/StructuralReloadLatencyTests.swift index dc763c7..dab8403 100644 --- a/Tests/PreviewsJITLinkTests/StructuralReloadLatencyTests.swift +++ b/Tests/PreviewsJITLinkTests/StructuralReloadLatencyTests.swift @@ -36,19 +36,13 @@ struct StructuralReloadLatencyTests { // Warm up: first compile pays module-cache warmup, first agent pays spawn cost. let warm = try await session.compileObjectForJIT() - try await reloader.renderObject( - at: warm.objectPath, supportObjectPaths: warm.supportObjectPaths, - archivePaths: warm.archivePaths, dylibPaths: warm.dylibPaths, - entrySymbol: warm.entrySymbol) + 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.renderObject( - at: build.objectPath, supportObjectPaths: build.supportObjectPaths, - archivePaths: build.archivePaths, dylibPaths: build.dylibPaths, - entrySymbol: build.entrySymbol) + try await reloader.render(build) let t2 = clock.now let compileMs = Self.ms(t0.duration(to: t1)) diff --git a/Tests/PreviewsMacOSTests/PreviewHostJITReloadTests.swift b/Tests/PreviewsMacOSTests/PreviewHostJITReloadTests.swift index 963b2b2..19b50fa 100644 --- a/Tests/PreviewsMacOSTests/PreviewHostJITReloadTests.swift +++ b/Tests/PreviewsMacOSTests/PreviewHostJITReloadTests.swift @@ -10,11 +10,8 @@ struct PreviewHostJITReloadTests { final class RecordingReloader: StructuralReloader, @unchecked Sendable { private(set) var calls: [(objectPath: URL, entrySymbol: String)] = [] - func renderObject( - at objectPath: URL, supportObjectPaths: [URL], archivePaths: [URL], dylibPaths: [URL], - entrySymbol: String - ) async throws { - calls.append((objectPath: objectPath, entrySymbol: entrySymbol)) + func render(_ build: JITRenderBuild) async throws { + calls.append((objectPath: build.objectPath, entrySymbol: build.entrySymbol)) } } From 9814f8c4e9431a7688f83093f3fbbf53e7de7994 Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Thu, 4 Jun 2026 17:15:24 -0400 Subject: [PATCH 45/46] JIT tests: relax two timing thresholds for slow CI runners (#191) The GitHub macos runners are ~15-28x slower than a dev machine, so two hard thresholds tuned locally failed on CI (a ~280ms reload measured ~7.8s there). Neither is a real regression. - StructuralReloadLatencyTests: raise the total cap 5000 -> 30000ms (a hang/ regression sanity check, not a perf gate). - SplitCompileTests: assert direction-only (splitMs < wholeMs) instead of a 20% margin; the split recompiles one file vs N+1, so it is faster and the gap widens with N, but the ratio compresses under CI noise. Co-Authored-By: Claude Opus 4.8 --- Tests/PreviewsJITLinkTests/SplitCompileTests.swift | 2 +- Tests/PreviewsJITLinkTests/StructuralReloadLatencyTests.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/PreviewsJITLinkTests/SplitCompileTests.swift b/Tests/PreviewsJITLinkTests/SplitCompileTests.swift index acabbf1..3672593 100644 --- a/Tests/PreviewsJITLinkTests/SplitCompileTests.swift +++ b/Tests/PreviewsJITLinkTests/SplitCompileTests.swift @@ -165,6 +165,6 @@ struct SplitCompileTests { "P4.1 split compile (N=\(count)): editable-only=\(Int(splitMs))ms " + "whole-module=\(Int(wholeMs))ms" ) - #expect(splitMs < wholeMs * 0.8) + #expect(splitMs < wholeMs) } } diff --git a/Tests/PreviewsJITLinkTests/StructuralReloadLatencyTests.swift b/Tests/PreviewsJITLinkTests/StructuralReloadLatencyTests.swift index dab8403..3a4f022 100644 --- a/Tests/PreviewsJITLinkTests/StructuralReloadLatencyTests.swift +++ b/Tests/PreviewsJITLinkTests/StructuralReloadLatencyTests.swift @@ -53,6 +53,6 @@ struct StructuralReloadLatencyTests { + "total=\(Int(compileMs + renderMs))ms" ) - #expect(compileMs + renderMs < 5000) + #expect(compileMs + renderMs < 30000) } } From d4f56d3c3f695b21491e0b4875bcd2448d3c6300 Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Thu, 4 Jun 2026 17:15:31 -0400 Subject: [PATCH 46/46] G2 chunk 1: FileWatcher delivers the changed file path (#191) FileWatcher's FSEvents trampoline already knows which watched path matched but discarded it, calling a 0-arg callback. Change the callback to (String) -> Void and pass the matched canonical path, so a watcher over a multi-file set can tell which file changed (the prerequisite for identity-based reload routing). Callers that do not need it ignore the argument. BuildSystemTests.fileWatcherDeliversChangedPath: editing the second of two watched files delivers that file's path to the callback. Co-Authored-By: Claude Opus 4.8 --- .../Handlers/PreviewStartHandler.swift | 2 +- Sources/PreviewsCore/FileWatcher.swift | 10 +++---- Sources/PreviewsMacOS/HostApp.swift | 2 +- .../PreviewsCoreTests/BuildSystemTests.swift | 28 ++++++++++++++++++- .../PreviewsCoreTests/IntegrationTests.swift | 8 +++--- .../PreviewsMacOSTests/PreviewHostTests.swift | 2 +- 6 files changed, 39 insertions(+), 13 deletions(-) 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/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/PreviewsMacOS/HostApp.swift b/Sources/PreviewsMacOS/HostApp.swift index 7db6970..2e91495 100644 --- a/Sources/PreviewsMacOS/HostApp.swift +++ b/Sources/PreviewsMacOS/HostApp.swift @@ -177,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 } 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/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)