Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,10 @@ All loaded via dlopen/dlsym inside the iOS host app. Does NOT use IndigoHID/Simu

The `main` branch has branch protections — all changes must go through a pull request. Always create a feature branch before committing. Use worktrees when working in parallel with other agents to avoid conflicts.

### Verifying a PR before merge

CI is disabled — the GitHub Actions `CI` and `Cache warmer` workflows are turned off and the `required_status_checks` rule has been removed from the `main` ruleset, so nothing gates a merge automatically. Verification is local and mandatory: a PR does **not** merge until **all unit tests pass** and **the example integration tests pass via the `integration-test` skill**. Run the unit and JIT suites with `swift test` (use `--filter PreviewsJITLinkTests --no-parallel` for the JIT tests), then run the `integration-test` skill for the example end-to-end coverage. No CI is not a license to merge broken code.

## Test Notes

### Test architecture
Expand Down
73 changes: 73 additions & 0 deletions Sources/PreviewsCore/Compiler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,79 @@ public actor Compiler {
return StableModule(moduleName: moduleName, modulesDir: moduleDir, objectPath: objectFile)
}

/// Per-module incremental build directory, reused across edits so the driver's
/// `.swiftdeps` records and the unchanged-file objects persist between compiles.
private var incrementalDirs: [String: URL] = [:]

/// Compile the whole target module incrementally: the editable `overlaySource` plus the
/// target's other `bulkFiles`, all under one `moduleName`. The Swift driver recompiles only
/// what changed — the overlay alone on a body edit, the overlay plus its dependents on an
/// interface edit — and reuses the rest from the persistent build dir. Returns the overlay's
/// object and the bulk objects (in `bulkFiles` order). This is the non-leaf structural split:
/// the bulk references the edited file, so a one-directional prebuilt stable module cannot be
/// used, but a single module resolves references in both directions.
public func compileModuleIncremental(
overlaySource: String,
bulkFiles: [URL],
moduleName: String,
extraFlags: [String] = []
) async throws -> (overlayObject: URL, bulkObjects: [URL]) {
let dir: URL
if let existing = incrementalDirs[moduleName] {
dir = existing
} else {
dir = workDir.appendingPathComponent("incremental-\(moduleName)", isDirectory: true)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
incrementalDirs[moduleName] = dir
}

let overlayFile = dir.appendingPathComponent("overlay.swift")
try overlaySource.write(to: overlayFile, atomically: true, encoding: .utf8)
let overlayObject = dir.appendingPathComponent("overlay.o")

// The output-file-map keys must match the command-line paths exactly, or the driver
// disables incremental ("no swiftDeps file") and recompiles everything every edit.
var fileMap: [String: [String: String]] = [
"": ["swift-dependencies": dir.appendingPathComponent("master.swiftdeps").path],
overlayFile.path: [
"object": overlayObject.path,
"swift-dependencies": dir.appendingPathComponent("overlay.swiftdeps").path,
],
]
var bulkObjects: [URL] = []
for (index, file) in bulkFiles.enumerated() {
let object = dir.appendingPathComponent("bulk_\(index).o")
bulkObjects.append(object)
fileMap[file.path] = [
"object": object.path,
"swift-dependencies": dir.appendingPathComponent("bulk_\(index).swiftdeps").path,
]
}
let mapFile = dir.appendingPathComponent("output-file-map.json")
let mapData = try JSONSerialization.data(withJSONObject: fileMap, options: [.sortedKeys])
try mapData.write(to: mapFile)

var args: [String] = [
swiftcPath,
"-incremental",
"-emit-object",
"-parse-as-library",
"-target", targetTriple,
"-sdk", sdkPath,
"-module-name", moduleName,
"-Onone",
"-gnone",
"-module-cache-path", moduleCachePath.path,
"-output-file-map", mapFile.path,
]
args += extraFlags
args += [overlayFile.path]
args += bulkFiles.map(\.path)

try await run(args)
return (overlayObject, bulkObjects)
}

// MARK: - Private

@discardableResult
Expand Down
81 changes: 72 additions & 9 deletions Sources/PreviewsCore/PreviewSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ public struct JITRenderBuild: Sendable {
/// 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]
/// Require a freshly respawned agent for this render instead of a new generation on the
/// live one. The non-leaf incremental split compiles under the target's own (stable) module
/// name, so its `@Observable DesignTimeStore` would re-register across generations in one
/// process; a fresh process each structural edit sidesteps the duplicate registration.
public let requiresFreshAgent: Bool

public init(
objectPath: URL,
Expand All @@ -35,7 +40,8 @@ public struct JITRenderBuild: Sendable {
literals: [LiteralEntry],
supportObjectPaths: [URL] = [],
archivePaths: [URL] = [],
dylibPaths: [URL] = []
dylibPaths: [URL] = [],
requiresFreshAgent: Bool = false
) {
self.objectPath = objectPath
self.imagePath = imagePath
Expand All @@ -45,6 +51,7 @@ public struct JITRenderBuild: Sendable {
self.supportObjectPaths = supportObjectPaths
self.archivePaths = archivePaths
self.dylibPaths = dylibPaths
self.requiresFreshAgent = requiresFreshAgent
}
}

Expand Down Expand Up @@ -237,19 +244,45 @@ public actor PreviewSession {
var supportObjectPaths: [URL] = []
var archivePaths: [URL] = []
var dylibPaths: [URL] = []
var requiresFreshAgent = false
if let (ctx, bulk) = splitContext {
let stable = try await stableModule(for: bulk, context: ctx)
supportObjectPaths = [stable.objectPath]
archivePaths = Self.dependencyArchives(in: ctx.compilerFlags)
if let runtimeArchive = try await Toolchain.compilerRuntimeArchivePath() {
archivePaths.append(URL(fileURLWithPath: runtimeArchive))
}
dylibPaths = Self.dependencyDylibs(in: ctx.compilerFlags)
objectPath = try await compiler.compileObject(
source: generated.source,
moduleName: "PreviewEdit_\(ctx.moduleName)_\(Self.uniqueModuleToken())",
extraFlags: ["-I", stable.modulesDir.path] + ctx.compilerFlags
)

if let stable = try await stableModuleIfLeaf(for: bulk, context: ctx) {
supportObjectPaths = [stable.objectPath]
objectPath = try await compiler.compileObject(
source: generated.source,
moduleName: "PreviewEdit_\(ctx.moduleName)_\(Self.uniqueModuleToken())",
extraFlags: ["-I", stable.modulesDir.path] + ctx.compilerFlags
)
} else {
// Non-leaf: the bulk references the edited file, so it cannot be prebuilt as a
// one-directional stable module. Compile the whole module incrementally with the
// overlay in-module (no `@testable import`); only the hot file recompiles per edit.
let overlay = BridgeGenerator.generateCombinedSource(
originalSource: source,
closureBody: preview.closureBody,
previewIndex: previewIndex,
platform: platform,
traits: traits,
renderOutputPath: imagePath.path,
designTimeValuesPath: valuesPath.path,
stableModuleImport: nil
)
let built = try await compiler.compileModuleIncremental(
overlaySource: overlay.source,
bulkFiles: bulk,
moduleName: ctx.moduleName,
extraFlags: ctx.compilerFlags
)
supportObjectPaths = built.bulkObjects
objectPath = try Self.uniqueObjectCopy(of: built.overlayObject)
requiresFreshAgent = true
}
} else {
objectPath = try await compiler.compileObject(
source: generated.source,
Expand All @@ -269,7 +302,8 @@ public actor PreviewSession {
literals: generated.literals,
supportObjectPaths: supportObjectPaths,
archivePaths: archivePaths,
dylibPaths: dylibPaths
dylibPaths: dylibPaths,
requiresFreshAgent: requiresFreshAgent
)
lastJITBuild = build
return build
Expand Down Expand Up @@ -342,6 +376,35 @@ public actor PreviewSession {
/// 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 var bulkIsNonLeaf = false

/// The stable half of the leaf split, or nil when the bulk references the hot file (non-leaf)
/// and so cannot compile without it. Cached per session: once the bulk fails to compile on its
/// own, every later edit takes the incremental whole-module path. A genuine bulk error surfaces
/// again from that path, so the fallback never hides it.
private func stableModuleIfLeaf(
for bulk: [URL], context ctx: BuildContext
) async throws -> Compiler.StableModule? {
if bulkIsNonLeaf { return nil }
do {
return try await stableModule(for: bulk, context: ctx)
} catch is CompilationError {
bulkIsNonLeaf = true
return nil
}
}

/// Copy the incremental build's reused `overlay.o` (a stable path) to a unique path, so each
/// structural edit presents a distinct `objectPath`. The reloader keys its literal fast path on
/// object-path identity, which a stable path would falsely trigger.
private static func uniqueObjectCopy(of object: URL) throws -> URL {
let dest = object.deletingLastPathComponent()
.appendingPathComponent("overlay-\(uniqueModuleToken()).o")
try? FileManager.default.removeItem(at: dest)
try FileManager.default.copyItem(at: object, to: dest)
return dest
}

private func stableModule(
for bulk: [URL], context ctx: BuildContext
) async throws -> Compiler.StableModule {
Expand Down
9 changes: 5 additions & 4 deletions Sources/PreviewsJITLink/JITStructuralReloader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ public actor JITStructuralReloader: StructuralReloader {
return
}

let session = try nextSession()
let session = try nextSession(forceFresh: build.requiresFreshAgent)
for dylib in build.dylibPaths {
try session.addDylib(path: dylib.path)
}
Expand All @@ -49,9 +49,10 @@ public actor JITStructuralReloader: StructuralReloader {

/// 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 {
/// kills its process). The first edit, each post-cap edit, and any `forceFresh` edit (the
/// non-leaf incremental split, which reuses the target's stable module name) start a new agent.
private func nextSession(forceFresh: Bool) throws -> JITSession {
if let session, !forceFresh, generation < generationCap {
generation += 1
try session.newGeneration()
return session
Expand Down
20 changes: 16 additions & 4 deletions Sources/PreviewsJITLinkCxx/PreviewsJITLinkCxx.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ class PreviewsAnonymousMapper : public llvm::orc::MemoryMapper {
char *prepare(llvm::orc::ExecutorAddr addr, size_t contentSize) override {
std::lock_guard<std::mutex> lock(mutex);
auto r = reservations.upper_bound(addr);
if (r == reservations.begin()) {
return nullptr;
}
--r;
return r->second.workingBuf + (addr - r->first);
}
Expand All @@ -117,6 +120,11 @@ class PreviewsAnonymousMapper : public llvm::orc::MemoryMapper {
{
std::lock_guard<std::mutex> lock(mutex);
auto r = reservations.upper_bound(ai.MappingBase);
if (r == reservations.begin()) {
return onInitialized(llvm::createStringError(
llvm::inconvertibleErrorCode(),
"initialize: no reservation covers mapping base"));
}
--r;
base = r->second.workingBuf + (ai.MappingBase - r->first);
}
Expand Down Expand Up @@ -273,13 +281,17 @@ struct previewsmcp_jit_session {
namespace {
llvm::Expected<llvm::orc::ExecutorAddr>
lookupInitialized(previewsmcp_jit_session *session, const char *symbol_name) {
if (!session->initialized) {
{
static std::mutex initMutex;
std::lock_guard<std::mutex> lock(initMutex);
if (auto err = session->jit->initialize(*session->jd)) {
return std::move(err);
// Re-check under the lock: the flag is read and written only here, so two
// threads cannot both pass the check and double-initialize the JITDylib.
if (!session->initialized) {
if (auto err = session->jit->initialize(*session->jd)) {
return std::move(err);
}
session->initialized = true;
}
session->initialized = true;
}
return session->jit->lookup(*session->jd, symbol_name);
}
Expand Down
24 changes: 19 additions & 5 deletions Sources/PreviewsMacOS/Snapshot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,27 @@ public enum Snapshot {
guard bounds.width > 0, bounds.height > 0 else {
throw SnapshotError.captureFailed
}
// Note: bitmapImageRepForCachingDisplay produces 1x images. Off-screen headless
// windows aren't associated with a display, so backingScaleFactor is 1.0 anyway.
// To capture at a specific scale, create an NSBitmapImageRep manually with scaled
// pixel dimensions. (pointfreeco/swift-snapshot-testing has the same limitation.)
guard let bitmapRep = contentView.bitmapImageRepForCachingDisplay(in: bounds) else {
// Pin the raster to a deterministic 1x (one pixel per point). bitmapImageRepForCachingDisplay
// inherits the host window's backingScaleFactor — 2x on a Retina display — which made the
// snapshot's pixel dimensions depend on which machine the daemon ran on. Building the rep
// manually with pixel dimensions equal to the point bounds keeps output reproducible.
guard
let bitmapRep = NSBitmapImageRep(
bitmapDataPlanes: nil,
pixelsWide: Int(bounds.width.rounded()),
pixelsHigh: Int(bounds.height.rounded()),
bitsPerSample: 8,
samplesPerPixel: 4,
hasAlpha: true,
isPlanar: false,
colorSpaceName: .deviceRGB,
bytesPerRow: 0,
bitsPerPixel: 0
)
else {
throw SnapshotError.captureFailed
}
bitmapRep.size = bounds.size
contentView.cacheDisplay(in: bounds, to: bitmapRep)

switch format {
Expand Down
4 changes: 3 additions & 1 deletion Tests/MCPIntegrationTests/MacOSMCPTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -525,7 +525,9 @@ struct MacOSMCPTests {
to: URL(fileURLWithPath: filePath), atomically: false, encoding: .utf8)

// CI swiftc on cold caches is slow; AGENTS.md notes daemon startup alone is 5–10s.
try await server.awaitStderrContains("Compiled:", timeout: .seconds(90))
// "Reloaded" matches both daemon kinds: the JIT agent logs "Reloaded (JIT agent)!" and
// the non-JIT recompile logs "Reloaded!". Both fire only after a successful structural edit.
try await server.awaitStderrContains("Reloaded", timeout: .seconds(90))
_ = try await server.awaitSnapshotChange(
sessionID: sessionID, baseline: baseline, timeout: .seconds(15)
)
Expand Down