Skip to content

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

Open
obj-p wants to merge 3 commits into
mainfrom
jit-nonleaf-hotreload
Open

JIT: hot-reload non-leaf files via single-module incremental split#194
obj-p wants to merge 3 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.

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).

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 in
    ~24s against the JIT daemon, where main times out at 90s.
  • Full PreviewsJITLinkTests suite: 50/50 passed serially (--no-parallel).

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)

obj-p and others added 3 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>
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