Skip to content

JIT executor Phase 3: SwiftUI session-lifecycle integration + hot-reload#190

Merged
obj-p merged 46 commits into
mainfrom
jit-phase3-session-integration
Jun 5, 2026
Merged

JIT executor Phase 3: SwiftUI session-lifecycle integration + hot-reload#190
obj-p merged 46 commits into
mainfrom
jit-phase3-session-integration

Conversation

@obj-p

@obj-p obj-p commented Jun 2, 2026

Copy link
Copy Markdown
Owner

Phase 3 of the custom JIT executor: wire it into PreviewsMCP's session lifecycle (design §8 Phase 3). Tracks #189. Builds on Phase 1 (#185) and Phase 2 (#186). Next-phase work is tracked in #191.

CI does not build the JIT targets (no third_party LLVM there), so the new JIT tests run local-only; this PR keeps the non-JIT build green by depending only on a JIT-free StructuralReloader protocol in PreviewsCore, with the concrete impl injected at the executable behind one #if PREVIEWSMCP_JIT.

Landed

  • P3.1 — agent SwiftUI render harness: loads SwiftUI, runs a real View body, renders offscreen to a bitmap on the agent's main thread, over a contiguous anonymous executor-memory slab (macOS denies mprotect-exec on MAP_SHARED; shared-memory slab reverted).
  • P3.2 — hot update via agent respawn: recompile → respawn → re-render through the real Compiler (default arm64-apple-macosx14.0 target works in the agent; no compileObject change).
  • P3.4 — daemon session-lifecycle integration:
    • a agent renders a bitmap to a host-supplied file (file transport, no new wire code).
    • b BridgeGenerator render seam: renderPreviewToFile rasterizes via ImageRenderer to a baked path.
    • c-i StructuralReloader protocol (Core) + JITStructuralReloader (PreviewsJITLink) + host wiring: structural edits render in the agent; preview_snapshot serves the agent PNG; injected via #if PREVIEWSMCP_JIT + jitEnabled-only Package.swift deps.
    • c-ii literal-after-structural: render bridge seeds DesignTimeStore from a baked JSON; a literal edit rewrites the JSON and re-renders the same .o (no recompile).
    • d latency measured: ≈281ms structural (compile ≈218ms + link/respawn/render ≈62ms) — compile-bound.

Status

Phase 3 core complete. Plan/source of truth: docs/jit-executor-phase3-plan.md. Still draft pending the examples/ E2E verify and the recompile-narrowing that gets structural edits under the <200ms target — both tracked in #191 (recompile-narrowing + capped-persistent reloader + examples E2E + JIT-in-CI). P3.3 (in-place write_mem + handshake) stays conditional.

🤖 Generated with Claude Code

obj-p and others added 29 commits June 2, 2026 12:01
…nt (#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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
… 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) <noreply@anthropic.com>
…map 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) <noreply@anthropic.com>
… 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 { <Platform> ___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) <noreply@anthropic.com>
…e; anonymous mapper (P3.1b-iii) (#189)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…piler-target finding (#189)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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 <noreply@anthropic.com>
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) <noreply@anthropic.com>
…hunking (#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 <noreply@anthropic.com>
…ed 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 <noreply@anthropic.com>
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) <noreply@anthropic.com>
…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 <noreply@anthropic.com>
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) <noreply@anthropic.com>
…atch (#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) <noreply@anthropic.com>
)

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 <noreply@anthropic.com>
…utor 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) <noreply@anthropic.com>
…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) <noreply@anthropic.com>
…Core (#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 <noreply@anthropic.com>
#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 <noreply@anthropic.com>
…er 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) <noreply@anthropic.com>
… 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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…ile 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 <noreply@anthropic.com>
… 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 <noreply@anthropic.com>
… 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
obj-p and others added 17 commits June 4, 2026 11:45
…-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 <noreply@anthropic.com>
… 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_<moduleName> 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 <noreply@anthropic.com>
…e 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 <noreply@anthropic.com>
… 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 <noreply@anthropic.com>
…ep-loading gap (#191)

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 <noreply@anthropic.com>
… into the JIT agent (#191)

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<Dep>.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 <noreply@anthropic.com>
… the JIT agent (#191)

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 <F-dir>/<name>.framework/<name> 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 <noreply@anthropic.com>
…review 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 <noreply@anthropic.com>
…istent 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 <noreply@anthropic.com>
…der (#191)

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 <noreply@anthropic.com>
…place literal re-render (#191)

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 <noreply@anthropic.com>
…VM (#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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…he 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@obj-p obj-p marked this pull request as ready for review June 5, 2026 02:06
@obj-p obj-p merged commit a5b58cc into main Jun 5, 2026
4 of 5 checks passed
@obj-p obj-p deleted the jit-phase3-session-integration branch June 5, 2026 02:06
obj-p added a commit that referenced this pull request Jun 10, 2026
… plan

Adds the "Update 2026-06-09" section that supersedes the stale
"Immediate next step" pointer and the CI-green claims: PR #190 merged,
GitHub Actions CI is disabled, and the merge bar is now local (unit
tests plus the integration-test skill). Documents the non-leaf
duplicate/missing-symbol bug, the incremental whole-module fix this
branch lands, its tradeoffs, and the verification table.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
obj-p added a commit that referenced this pull request Jun 10, 2026
)

* Snapshot: pin window capture to deterministic 1x

bitmapImageRepForCachingDisplay inherits the host window's backingScaleFactor,
so on a Retina (2x) display the snapshot came out 800x1200 instead of the
logical 400x600, making output depend on which machine the daemon ran on.
Build the NSBitmapImageRep manually at the point bounds so capture is 1x and
reproducible across machines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* JIT: hot-reload non-leaf files via single-module incremental split

The structural split compiled the bulk into a prebuilt stable module that the
hot file imported. That is one-directional, so a non-leaf hot file (one the
bulk references, e.g. a previewed view used by another preview) failed with
"cannot find <type> in scope" when the bulk compiled without it.

Detect the non-leaf case (the stable-module compile fails) and compile the whole
target module incrementally instead: the editable overlay plus the other sources
under one module name, where the Swift driver recompiles only the hot file and
reuses the bulk objects. References resolve in both directions. Because the
overlay then uses the target's own module name, its @observable DesignTimeStore
would re-register across generations, so non-leaf builds respawn the agent each
structural edit.

hotReloadStructural now exercises this on the JIT daemon. Its sync marker moves
from "Compiled:" (only the non-JIT recompile logs it) to "Reloaded", which both
daemon kinds log on a successful structural edit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* JIT C++: fix lookupInitialized lock and anon-mapper bounds

lookupInitialized read session->initialized outside the lock with no re-check,
so two threads could both pass the guard and double-initialize the JITDylib
(and the bool itself was a data race). Hold the lock across the check and the
init so the flag is only touched under the lock.

PreviewsAnonymousMapper::prepare and ::initialize decremented the upper_bound
iterator with no bounds check, which is undefined behavior if the reservation
map is empty or the address precedes all reservations. Guard r == begin():
prepare returns nullptr, initialize reports an Expected error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Docs: record non-leaf fix, CI removal, and local merge bar in Phase 3 plan

Adds the "Update 2026-06-09" section that supersedes the stale
"Immediate next step" pointer and the CI-green claims: PR #190 merged,
GitHub Actions CI is disabled, and the merge bar is now local (unit
tests plus the integration-test skill). Documents the non-leaf
duplicate/missing-symbol bug, the incremental whole-module fix this
branch lands, its tradeoffs, and the verification table.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* JIT: render agent preview in an off-screen NSWindow, not ImageRenderer

ImageRenderer cannot rasterize AppKit-backed SwiftUI: with
NavigationStack { List } as the root it returns a nil cgImage (the
agent entry fails with status -1) and with any modifier wrapping the
List it returns a tiny blank raster at ideal size. Every real preview
hit one of the two; the daemon then either reported "Recompilation
failed" or served a blank snapshot. The cases slipped through because
the existing assertions only checked "snapshot changed" / "PNG
non-empty" and the session-start render goes through the dylib window
path, so the agent had never rendered an unedited body.

renderPreviewToFile now hosts the view in a borderless NSWindow
positioned off-screen via NSHostingView, which gives AppKit-backed
views a real backing hierarchy, and captures at a deterministic 1x
into an NSBitmapImageRep the same way Snapshot.capture does. No
run-loop settle is needed; cacheDisplay draws synchronously after
layoutSubtreeIfNeeded.

nonLeafRendersUneditedPreviewInAgent guards the regression: it renders
the unedited non-leaf ToDoView.swift (NavigationStack + List) through
compileObjectForJIT and the reloader, exactly the path a cross-file
bulk edit takes as a session's first structural reload.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* HostApp: drop stale agent PNG when the window reloads a dylib

snapshot() prefers the agent-rendered PNG whenever agentImagePaths has
an entry, but only the JIT reload paths wrote it and nothing cleared
it when the dylib window path re-rendered. After a session became
agent-backed, preview_switch and preview_configure updated the window
while snapshots kept serving the old agent PNG. loadPreview now
removes the entry, making the window the source of truth again until
the next JIT reload repopulates it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* gitignore: match the third_party symlink in worktrees

"/third_party/" only matches directories. Worktrees point third_party
at the main checkout's prebuilt LLVM via a symlink, which therefore
showed as untracked. Dropping the trailing slash covers both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant