Skip to content

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

Merged
obj-p merged 7 commits into
mainfrom
jit-nonleaf-hotreload
Jun 10, 2026
Merged

JIT: hot-reload non-leaf files via single-module incremental split#194
obj-p merged 7 commits into
mainfrom
jit-nonleaf-hotreload

Conversation

@obj-p

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

Copy link
Copy Markdown
Owner

Problem

main (a5b58cc, the #190 merge) shipped the JIT split-compile path with only
the leaf case handled. The split prebuilds a stable module from the target's
other sources and @testable imports it from the edited file. When the edited
file is non-leaf (another file in the target references it, e.g.
ToDoView.swift is used by ToDoProviderPreview.swift), the stable compile
excludes the hot file but the bulk still references it, so it fails with
cannot find 'ToDoView' in scope and the hot reload times out.

Running the full local merge bar (the integration-test skill) against this
branch then exposed two further agent-render bugs that main also has:

  1. ImageRenderer cannot rasterize AppKit-backed SwiftUI. With
    NavigationStack { List } as the root it returns a nil cgImage (the agent
    entry fails with status -1, surfaced as Recompilation failed: JIT render entry returned non-zero status -1 on any cross-file edit that was a
    session's first structural reload). With a modifier wrapping the List it
    returns a tiny blank raster instead. Existing tests passed because they only
    asserted "snapshot changed" / "PNG non-empty".
  2. Stale agent PNG shadowed the window. snapshot() prefers the
    agent-rendered PNG once a session is agent-backed, but the dylib window
    paths (preview_switch, preview_configure) never cleared it, so
    snapshots kept serving the pre-switch image.

Fix

Detect the non-leaf case (the stable-module compile throws) and compile the
whole target module incrementally instead: the edited file as an 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 both
directions, so the hot file lives in exactly one module.

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 JIT agent each structural edit (requiresFreshAgent).

For the render bugs:

  • renderPreviewToFile now hosts the view in a borderless off-screen
    NSWindow via NSHostingView and captures at a deterministic 1x, the same
    mechanism as Snapshot.capture. AppKit-backed views get a real backing
    hierarchy and render with full content.
  • loadPreview drops the session's agentImagePaths entry, making the window
    the source of truth again until the next JIT reload repopulates it.

Design notes / known tradeoffs

  • The leaf fast path (stable module + capped-persistent agent) is unchanged and
    still serves the common case (editing the preview-bearing leaf file).
  • Non-leaf edits lose capped-persistent and respawn the agent each edit.
  • Leaf vs non-leaf is detected by compile-and-catch and latches per session
    (bulkIsNonLeaf). A real error in another bulk file would pin the session to
    the slower whole-module path. Acceptable: it degrades speed, not correctness,
    and the error still surfaces. Follow-up candidate.
  • The first non-leaf edit compiles the bulk twice (once to fail-detect).

Verification

  • hotReloadStructural (edits ToDoView.swift, the non-leaf file) passes
    against the JIT daemon, where main times out at 90s.
  • New nonLeafRendersUneditedPreviewInAgent renders the unedited non-leaf
    ToDoView.swift through compileObjectForJIT + the reloader (the exact
    cross-file-first path), failing on main's ImageRenderer entry.
  • Full PreviewsJITLinkTests: 51/51 serially. Touched-module unit suites
    (Core/MacOS/Engine): 320/320. macOS CLI command suites: 46/46 serially.
    macOS MCP integration suite: 7/7.
  • integration-test skill, macOS scope, all four examples (SPM, Bazel,
    xcodeproj, xcworkspace): rendering, literal + cross-file hot reload,
    multi-preview switch with invalid-index rollback, trait injection +
    persistence across switch, PreviewProvider. Daemon log clean of reload
    failures.

Commits

  • Snapshot: pin window capture to deterministic 1x (independent, also stranded
    off main)
  • JIT: hot-reload non-leaf files via single-module incremental split (the fix)
  • JIT C++: fix lookupInitialized lock and anon-mapper bounds (JIT-link
    hardening)
  • Docs: record non-leaf fix, CI removal, and local merge bar in Phase 3 plan
  • JIT: render agent preview in an off-screen NSWindow, not ImageRenderer
  • HostApp: drop stale agent PNG when the window reloads a dylib
  • gitignore: match the third_party symlink in worktrees

🤖 Generated with Claude Code

obj-p and others added 7 commits June 8, 2026 10:39
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>
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>
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>
… 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>
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>
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>
"/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>
@obj-p obj-p merged commit 5e8badc into main Jun 10, 2026
@obj-p obj-p deleted the jit-nonleaf-hotreload branch June 10, 2026 00:24
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